feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构

- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320
- 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边
- uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页
- 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量
- 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*,
  运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
This commit is contained in:
2026-11-08 21:26:47 +08:00
parent 37479f48c7
commit 8fdc55e17d
40 changed files with 2421 additions and 1360 deletions
+406 -177
View File
@@ -48,7 +48,7 @@
.page-container {
width: 100%;
max-width: 1600px;
max-width: 1320px;
min-width: 0;
margin: 0 auto;
}
@@ -139,7 +139,8 @@
}
.toolbar-search {
flex: 1 1 260px;
flex: 1 1 200px;
min-width: 180px;
max-width: 420px;
}
@@ -151,7 +152,7 @@
}
.filter-select {
min-width: 140px;
min-width: 110px;
}
.task-name-cell {
@@ -227,16 +228,6 @@
gap: 4px;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
border-top: 1px solid var(--su-border);
}
/* ---------- 任务编辑器 ---------- */
.task-editor-modal {
@@ -651,6 +642,19 @@
background-color: var(--su-bg-subtle);
}
/* 文件模式下选中的文件 */
.directory-row-selected,
.directory-row-selected:hover {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
.directory-check {
margin-left: auto;
flex-shrink: 0;
color: var(--su-color-primary);
}
.directory-icon {
flex-shrink: 0;
color: var(--su-text-muted);
@@ -956,28 +960,6 @@
/* ---------- Python 镜像 ---------- */
.mirror-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.mirror-summary-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.mirror-summary-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.mirror-card {
overflow: hidden;
}
@@ -1197,10 +1179,145 @@
height: 50vh;
flex: none;
}
}
.pagination-bar {
align-items: flex-start;
flex-direction: column;
/* ---------- uv 配置弹窗 ---------- */
.uv-settings-modal {
width: min(720px, calc(100vw - 32px));
max-width: 720px;
}
.uv-settings {
display: flex;
flex-direction: column;
}
/* 标签页:三段配置共用一块面板,内容自己撑高度 */
.uv-tabs {
margin-bottom: 4px;
}
.uv-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.uv-section {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 来源那一行:路径输入框 + 右侧动作(下载 / 浏览) */
.uv-path-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
/* 程序目录的 uv:固定位置,只读展示,不做成输入框 */
.uv-path-static {
display: flex;
height: var(--su-control-height-md);
align-items: center;
gap: 8px;
overflow: hidden;
border: 1px dashed var(--su-border-strong);
border-radius: var(--su-radius-sm);
padding: 0 10px;
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
font-family: var(--su-font-mono);
font-size: var(--su-font-size-sm);
}
.uv-path-static-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uv-mirror-hint code {
font-family: var(--su-font-mono);
color: var(--su-text-sub);
}
/* 镜像源弹窗:PyPI 表格列多,比默认 lg 宽一点 */
.mirror-settings-modal {
width: min(760px, calc(100vw - 32px));
max-width: 760px;
}
/* 固定的路径变量:不可编辑、不可删除,样式上做出区别 */
.uv-env-locked {
display: flex;
height: var(--su-control-height-md);
align-items: center;
gap: 8px;
overflow: hidden;
border: 1px dashed var(--su-border-strong);
border-radius: var(--su-radius-sm);
padding: 0 10px;
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
font-family: var(--su-font-mono);
font-size: var(--su-font-size-sm);
}
.uv-env-locked-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uv-version-input {
width: 160px;
}
.uv-inline-state {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.uv-empty {
min-height: 0;
padding: 8px 0;
}
.uv-python-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.uv-python-item {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.uv-python-version {
font-variant-numeric: tabular-nums;
}
.uv-python-path {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.uv-python-item {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
}
@@ -1231,37 +1348,53 @@
padding: 12px 16px;
}
/* ---------- 顶部导航(三栏工作台) ---------- */
/* 虚拟环境管理弹窗:标题 + 说明在左,操作在右 */
.env-manager-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.env-manager-head > :first-child {
min-width: 0;
flex: 1;
}
.env-manager-empty {
min-height: 0;
align-items: center;
justify-content: center;
padding: 40px 24px;
text-align: center;
}
/* ---------- 顶部导航 ---------- */
/* 整条由 Layout 的 header 承担(实底 + 下边线),这里只管内部:左品牌、右图标 */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 24px;
width: 100%;
padding: 0 8px;
max-width: 1320px;
height: 100%;
margin: 0 auto;
padding: 0 16px;
}
.app-header-brand {
.app-header-actions {
display: flex;
flex: none;
align-items: center;
gap: 12px;
min-width: 0;
gap: 4px;
}
.app-header-nav {
display: flex;
align-items: center;
gap: 6px;
}
.app-header-status {
margin-left: auto;
}
/* ---------- 三栏工作台 ---------- */
/* ---------- 三栏工作台:左右各 1 份、中间 2 份(参考站 3/6/3) ---------- */
.dashboard {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
padding: 16px 16px 40px;
@@ -1287,27 +1420,26 @@
.dash-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
gap: 12px 16px;
margin-top: 12px;
}
/* 概览指标:标签与数字在各自格子里居中 */
.dash-metric {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px;
border: 1px solid var(--su-border);
border-radius: 8px;
background-color: var(--su-bg-subtle);
text-align: center;
}
.dash-metric-value {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
}
.dash-metric-danger {
color: var(--su-color-danger);
font-weight: var(--su-font-weight-bold);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.dash-list {
@@ -1345,57 +1477,185 @@
white-space: nowrap;
}
.task-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.task-item {
/* 首页「镜像源」卡片:默认镜像 + 运行时注入的环境变量 */
.mirror-card-summary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid var(--su-border);
border-radius: 8px;
background-color: var(--su-bg-subtle);
min-width: 0;
gap: 4px;
margin-top: 10px;
}
.task-item-main {
.mirror-card-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mirror-card-inject {
margin-top: 8px;
}
/* 同一行放两组键值:uv 卡片里的「版本 + Python 解释器」 */
.dash-inline-kv {
display: flex;
align-items: flex-start;
gap: 10px;
align-items: baseline;
gap: 16px;
margin-top: 10px;
min-width: 0;
flex-wrap: wrap;
}
.dash-inline-item {
display: inline-flex;
min-width: 0;
align-items: baseline;
gap: 6px;
}
.dash-inline-item > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 最近触发:任务名在左、触发时间在右,名字过长时省略 */
.dash-inline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
min-width: 0;
}
.task-item-mark {
display: grid;
place-items: center;
.dash-inline-name {
min-width: 0;
}
.dash-inline-time {
flex: none;
width: 32px;
height: 32px;
border: 1px solid var(--su-border);
border-radius: 8px;
background-color: var(--su-bg-card);
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.task-item-text {
.dash-inline-empty {
display: block;
margin-top: 8px;
}
/* 每个任务一张卡片:标题行(任务名 + 类型 + 活跃状态 + 右上角详情)、内容行(说明 · Cron/下次 · 操作) */
.task-card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
/* 标题只占自身文字宽度:类型与活跃中标签紧跟任务名,详情按钮始终贴右 */
.task-card-title {
flex: 0 1 auto;
min-width: 0;
}
.task-card-detail {
margin-left: auto;
}
/* 标题旁的状态标签:整体比默认标签小约三分之一(高 16、字 11、圆点 4) */
.task-card-head .task-card-status-badge {
gap: 3px;
height: 16px;
padding: 0 6px;
font-size: 11px;
line-height: 1;
}
.task-card-head .task-card-status-badge > span {
width: 4px;
height: 4px;
}
.task-card {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 8px 16px;
}
.task-card-main {
display: flex;
flex: 1 1 160px;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.task-item-actions {
/* 右侧调度块:Cron 与下次运行在左,操作按钮贴右,整块从更靠左的位置起排 */
.task-card-plan {
display: flex;
flex: 0 0 auto;
min-width: 136px;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.task-card-next {
display: inline-flex;
align-items: center;
border: 1px solid var(--su-border);
border-radius: var(--su-radius-sm);
padding: 2px 8px;
color: var(--su-text-sub);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
white-space: nowrap;
}
/* 操作按钮贴卡片内容区的底边,和「下次运行」那一行齐平 */
.task-card-side {
display: flex;
flex: none;
align-self: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.task-card-actions {
justify-content: flex-end;
}
/* 任务详情弹窗:只读的键值列表 */
.detail-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.detail-value {
min-width: 0;
color: var(--su-text-main);
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
overflow-wrap: anywhere;
white-space: pre-line;
}
@media (max-width: 1200px) {
.dashboard {
grid-template-columns: 240px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
}
.dashboard-right {
@@ -1414,108 +1674,77 @@
grid-template-columns: minmax(0, 1fr);
}
.task-grid {
grid-template-columns: minmax(0, 1fr);
}
.app-header {
flex-wrap: wrap;
}
}
/* ---------- 左栏用户卡片 ---------- */
.user-card {
/* ---------- 左栏用户卡片:居中的资料卡 ---------- */
.user-profile {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 6px 4px 2px;
text-align: center;
}
/* Avatar 的尺寸走组件自己的局部变量(见 Avatar.module.css),这里只放大一档 */
.user-profile-avatar {
--avatar-size: 84px;
--avatar-badge: 22px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: 28px;
}
.user-profile-name-row {
display: flex;
align-items: center;
gap: 12px;
gap: 6px;
margin-top: 2px;
}
.user-avatar {
position: relative;
flex: none;
}
.user-online {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
border: 2px solid var(--su-bg-card);
border-radius: 50%;
background-color: #22c55e;
}
.user-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.user-dot {
display: inline-block;
.user-profile-status {
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background-color: #22c55e;
background-color: var(--su-color-success);
}
.user-bio {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid var(--su-border);
/* 参考稿是描边胶囊,Tag 默认是浅底,这里补一圈边框 */
.user-profile-role {
border: 1px solid var(--su-border-strong);
background-color: transparent;
color: var(--su-text-secondary);
}
.user-profile-bio {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--su-text-muted);
font-size: 12px;
line-height: 1.6;
font-size: 13px;
line-height: 1.7;
}
/* 弹窗里的多个区块 */
.modal-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
/* uv 弹窗:环境变量左标签 + 底部操作 */
/* 弹窗表单:区块标题 + 选项 chip + 底部操作条 */
.form-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-section-head {
display: flex;
.user-profile-meta {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 6px;
margin-top: 2px;
}
.form-section-icon {
color: var(--su-text-muted);
.user-profile-engine-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--su-color-success);
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
.user-profile-engine-dot.is-offline {
background-color: var(--su-color-danger);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
}
.form-actions > button:last-child {
flex: 1;
}
@media (max-width: 720px) {
.form-section-head {
flex-wrap: wrap;
}
}
+175 -39
View File
@@ -9,15 +9,11 @@ import App from './App'
const task: Task = {
id: 'echo_task',
name: 'Echo task',
description: 'Echo the configured message',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
enabled: true,
timeout_secs: 300,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: {
type: 'script',
working_dir: 'D:\\Projects\\echo',
@@ -53,17 +49,32 @@ interface FetchMockOptions {
}>
envPackages?: Record<string, Array<{ name: string; version: string }>>
pythons?: Array<{ version: string; path: string }>
/** 模拟「程序目录 uv 已安装」时的版本号,例如 `uv 0.9.0` */
uvVersion?: string
}
function uvSettingsPayload(
source: string,
customPath: string | null,
managedVersion: string | null = null,
mirrorUrl: string | null = null,
) {
const mirrorBase = mirrorUrl ?? 'https://mirrors.ustc.edu.cn/github-release'
return {
source,
custom_path: customPath,
extra_env: {},
env_defaults: {
UV_CACHE_DIR: 'uv-cache',
UV_PYTHON_INSTALL_DIR: 'python',
UV_PYTHON_INSTALL_MIRROR: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
},
managed_dir: 'D:\\TaskPilot\\uv',
python_dir: 'D:\\TaskPilot\\python',
cache_dir: 'D:\\TaskPilot\\uv-cache',
mirror_url: mirrorBase,
uv_download_url: `${mirrorBase}/astral-sh/uv/LatestRelease`,
python_mirror_url: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
executable:
source === 'managed'
? 'D:\\TaskPilot\\uv\\uv.exe'
@@ -93,7 +104,8 @@ function uvSettingsPayload(
function createFetchMock(options: FetchMockOptions = {}) {
let uvSource = 'managed'
let uvCustomPath: string | null = null
let uvManagedVersion: string | null = null
let uvManagedVersion: string | null = options.uvVersion ?? null
let uvMirror: string | null = null
let managedEnvs = options.envs ?? []
let managedPythons = options.pythons ?? []
const managedPackages = options.envPackages ?? {}
@@ -192,21 +204,37 @@ function createFetchMock(options: FetchMockOptions = {}) {
if (url.endsWith('/api/v1/uv/download')) {
uvSource = 'managed'
uvManagedVersion = 'uv 0.9.0'
return jsonResponse(uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion))
return jsonResponse(
uvSettingsPayload(
uvSource,
uvCustomPath,
uvManagedVersion,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/uv')) {
const request = init?.body
? (JSON.parse(String(init.body)) as {
source: string
custom_path?: string | null
mirror_url?: string | null
})
: null
if (request) {
uvSource = request.source
uvCustomPath = request.custom_path ?? null
if (request.mirror_url !== undefined) {
uvMirror = request.mirror_url
}
}
return jsonResponse(
uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion),
uvSettingsPayload(
uvSource,
uvCustomPath,
uvManagedVersion,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/cron/preview')) {
@@ -310,53 +338,91 @@ describe('TaskDashboard', () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText('Echo task')).toBeInTheDocument()
expect(screen.getByText('echo_task')).toBeInTheDocument()
// 任务名会同时出现在卡片和左下角「最近触发」里
expect((await screen.findAllByText('Echo task')).length).toBeGreaterThan(0)
expect(
screen.getByText('Echo the configured message'),
).toBeInTheDocument()
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
expect(await screen.findByText('在线')).toBeInTheDocument()
// 顶部品牌栏和用户卡片都会显示引擎状态
expect((await screen.findAllByText('引擎在线')).length).toBeGreaterThan(0)
})
it('navigates to the python settings page from the sidebar', async () => {
it('opens the uv settings dialog from the right column', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(
await screen.findByRole('button', { name: 'Python 环境' }),
await screen.findByRole('button', { name: '配置 uv' }),
)
expect(
await screen.findByRole('heading', { name: 'Python 环境' }),
).toBeInTheDocument()
// 镜像和 uv 在同一页
expect(
await screen.findByRole('row', { name: /清华 TUNA/ }),
).toBeInTheDocument()
expect(
await screen.findByText('uv 可执行文件'),
await screen.findByRole('heading', { name: 'uv 配置' }),
).toBeInTheDocument()
// 三段配置改用标签页切换:可执行文件 / 环境变量 / Python 解释器
expect(screen.getByRole('tab', { name: 'uv 可执行文件' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /环境变量/ })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Python 解释器/ })).toBeInTheDocument()
// 顶部不再重复首页已有的版本 / 数量 / 镜像地址
expect(screen.queryByText(/uv 下载/)).toBeNull()
})
it('saves the uv executable and env vars picked on the python page', async () => {
it('saves the uv download mirror from the uv dialog', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: 'Python 环境' }))
expect(await screen.findByText('未检测到可用的 uv')).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
const input = await screen.findByLabelText('下载镜像')
await user.clear(input)
await user.type(input, 'https://mirror.example/github-release')
await user.click(screen.getByRole('button', { name: '保存设置' }))
await user.click(
screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }),
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([request, init]) =>
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
),
).toBe(true)
})
const saved = fetchMock.mock.calls.find(
([request, init]) =>
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
)
expect(JSON.parse(String(saved?.[1]?.body)).mirror_url).toBe(
'https://mirror.example/github-release',
)
})
it('saves the uv executable and env vars from the uv dialog', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
expect(
await screen.findByRole('button', { name: '下载 uv' }),
).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: '指定路径' }))
await user.type(
screen.getByLabelText('uv 可执行文件'),
'D:\\tools\\uv.exe',
)
// 环境变量在第二个标签页里
await user.click(screen.getByRole('tab', { name: /环境变量/ }))
// 三个关键变量(两个路径 + 解释器镜像)固定在列表里,不能删除
const uvDialog = await screen.findByRole('dialog', { name: 'uv 配置' })
expect(
within(uvDialog).queryByRole('button', { name: '删除' }),
).toBeNull()
await user.click(screen.getByRole('button', { name: '+ 添加变量' }))
await user.type(screen.getByLabelText('环境变量名 1'), 'UV_CACHE_DIR')
await user.type(screen.getByLabelText('环境变量值 1'), 'D:\\uv-cache')
await user.click(screen.getByRole('button', { name: '保存' }))
await user.type(screen.getByLabelText('环境变量名 4'), 'UV_HTTP_TIMEOUT')
await user.type(screen.getByLabelText('环境变量值 4'), '60')
await user.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => {
expect(
@@ -373,17 +439,63 @@ describe('TaskDashboard', () => {
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
source: 'custom',
custom_path: 'D:\\tools\\uv.exe',
extra_env: { UV_CACHE_DIR: 'D:\\uv-cache' },
extra_env: { UV_HTTP_TIMEOUT: '60' },
mirror_url: 'https://mirrors.ustc.edu.cn/github-release',
})
})
it('picks the uv executable through the file browser', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
directories: (path) =>
jsonResponse(
path
? {
path,
parent: 'C:\\',
entries: [
{
name: 'uv.exe',
path: `${path}${path.endsWith('\\') ? '' : '\\'}uv.exe`,
is_dir: false,
},
],
truncated: false,
}
: {
path: null,
parent: null,
entries: [{ name: 'C:\\', path: 'C:\\', is_dir: true }],
truncated: false,
},
),
}),
)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(screen.getByRole('radio', { name: '指定路径' }))
await user.click(screen.getByRole('button', { name: '浏览' }))
expect(
await screen.findByRole('heading', { name: '选择 uv 可执行文件' }),
).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'C:\\' }))
await user.click(await screen.findByRole('button', { name: 'uv.exe' }))
await user.click(screen.getByRole('button', { name: '使用此文件' }))
expect(screen.getByLabelText('uv 可执行文件')).toHaveValue('C:\\uv.exe')
})
it('downloads uv from the mirror and switches to it', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: 'Python 环境' }))
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(await screen.findByRole('button', { name: '下载 uv' }))
await waitFor(() => {
@@ -395,16 +507,17 @@ describe('TaskDashboard', () => {
),
).toBe(true)
})
expect((await screen.findAllByText('uv 0.9.0')).length).toBeGreaterThan(0)
expect((await screen.findAllByText('0.9.0')).length).toBeGreaterThan(0)
})
it('downloads a python version with uv', async () => {
const fetchMock = createFetchMock()
it('downloads a python version from the uv dialog', async () => {
const fetchMock = createFetchMock({ uvVersion: 'uv 0.9.0' })
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(screen.getByRole('tab', { name: /Python 解释器/ }))
await user.type(screen.getByLabelText('要下载的 Python 版本'), '3.12')
await user.click(screen.getByRole('button', { name: '下载 Python' }))
@@ -440,8 +553,13 @@ describe('TaskDashboard', () => {
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
expect(await screen.findByText('还没有预创建的环境')).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: '管理' }))
const envDialog = await screen.findByRole('dialog', {
name: '虚拟环境管理',
})
expect(
await within(envDialog).findByText(/还没有预创建的环境/),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+ 新建环境' }))
await user.type(screen.getByLabelText('环境名'), 'data-science')
@@ -493,7 +611,7 @@ describe('TaskDashboard', () => {
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
await user.click(await screen.findByRole('button', { name: '管理' }))
await user.click(await screen.findByRole('button', { name: '查看包' }))
expect(await screen.findByText('numpy')).toBeInTheDocument()
@@ -533,11 +651,29 @@ describe('TaskDashboard', () => {
})
})
it('opens the task details from the card', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '详情' }))
const dialog = await screen.findByRole('dialog', { name: '任务详情' })
expect(within(dialog).getByText('echo_task')).toBeInTheDocument()
expect(
within(dialog).getByText('Echo the configured message'),
).toBeInTheDocument()
expect(within(dialog).getByText('D:\\Projects\\echo')).toBeInTheDocument()
})
it('shows the next run time reported by the schedule endpoint', async () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText(/下次/)).toBeInTheDocument()
// 卡片里直接显示排好的触发时间(MM/DD HH:mm)
expect(
(await screen.findAllByText(/^\d{2}\/\d{2} \d{2}:\d{2}$/)).length,
).toBeGreaterThan(0)
})
it('creates a script task with the visible form values', async () => {
+1
View File
@@ -24,6 +24,7 @@ function App() {
<Layout
className="app-root"
header={<AppHeader />}
headerVariant="solid"
maxWidth="full"
contentPadding="none"
>
+6 -1
View File
@@ -311,13 +311,18 @@ export function createPythonVenv(
/**
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
* `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。
*/
export function listDirectories(
path?: string,
options: { includeFiles?: boolean } = {},
signal?: AbortSignal,
): Promise<DirectoryListingResponse> {
return requestJson<DirectoryListingResponse>(
withQuery('/api/v1/system/directories', { path }),
withQuery('/api/v1/system/directories', {
path,
include_files: options.includeFiles ? 'true' : undefined,
}),
{ signal },
)
}
+20 -16
View File
@@ -456,8 +456,6 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
ConcurrencyPolicy: "skip";
CreateManagedEnvRequest: {
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
name: string;
@@ -478,12 +476,12 @@ export interface components {
};
CreateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
/** @description 一句话说明,展示在任务卡片和详情里 */
description?: string;
enabled?: boolean;
id: string;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
@@ -498,6 +496,8 @@ export interface components {
timezone: string;
};
DirectoryEntry: {
/** @description true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) */
is_dir: boolean;
/** @description 用于展示的名称 */
name: string;
/** @description 绝对路径,选中后回填到表单 */
@@ -574,16 +574,10 @@ export interface components {
/** Format: date-time */
updated_at: string;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: number;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
RunTrigger: "schedule" | "manual";
/** @description 已注册调度的任务及其下次触发时间。 */
ScheduledTaskResponse: {
/**
@@ -644,14 +638,14 @@ export interface components {
};
TaskResponse: {
action: components["schemas"]["TaskAction"];
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
/** Format: date-time */
created_at: string;
cron: string;
/** @description 一句话说明 */
description: string;
enabled: boolean;
id: string;
name: string;
retry_policy: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs: number;
timezone: string;
@@ -726,11 +720,11 @@ export interface components {
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
/** @description 一句话说明,展示在任务卡片和详情里 */
description?: string;
enabled?: boolean;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
@@ -741,6 +735,8 @@ export interface components {
extra_env?: {
[key: string]: string;
};
/** @description uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认 */
mirror_url?: string | null;
source: components["schemas"]["UvSource"];
};
UvCandidate: {
@@ -767,9 +763,15 @@ export interface components {
};
/** @description 程序目录下的 uv 目录 */
managed_dir: string;
/** @description 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认 */
mirror_url: string;
/** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */
python_dir: string;
/** @description Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`) */
python_mirror_url: string;
source: components["schemas"]["UvSource"];
/** @description uv 自身压缩包的下载地址前缀 */
uv_download_url: string;
version?: string | null;
};
/**
@@ -1553,6 +1555,8 @@ export interface operations {
query?: {
/** @description 绝对目录路径;留空表示列出盘符 */
path?: string;
/** @description 是否一并返回文件(挑选可执行文件时用);默认只返回目录 */
include_files?: boolean;
};
header?: never;
path?: never;
@@ -1560,7 +1564,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description Subdirectories of the requested path, or the drive list */
/** @description Subdirectories of the requested path (plus files when `include_files=true`), or the drive list */
200: {
headers: {
[name: string]: unknown;
+47 -32
View File
@@ -1,45 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { Brand, Space, Tag, Text } from '@stash/ui'
import { fetchHealth } from '../api/client'
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
import { Brand, Icon, IconButton, toast } from '@stash/ui'
import { useEffect, useState } from 'react'
type Scheme = 'light' | 'dark'
const THEME_STORAGE_KEY = 'taskpilot.theme'
function readScheme(): Scheme {
const saved = window.localStorage.getItem(THEME_STORAGE_KEY)
return saved === 'dark' ? 'dark' : 'light'
}
/**
* 顶部导航:品牌 + 引擎状态。
* 顶部栏:左边品牌、右边操作图标。
*
* 配置入口都收进任务调度页的三栏卡片里(uv / 镜像源 / 虚拟环境都用弹窗),
* 所以这里不再放页面级导航。
* 引擎状态、任务数量这些在左栏的用户卡片和「概览」里已经看得到,页头不再重复。
* 明暗切换只写本机偏好(`<html data-scheme>`),刷新会重拉页面数据;
* 「配置 / 退出」暂时没有对应入口,点了给个提示,不做假按钮。
*/
export function AppHeader() {
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: ({ signal }) => fetchHealth(signal),
refetchInterval: 30_000,
})
const queryClient = useQueryClient()
const [scheme, setScheme] = useState<Scheme>(readScheme)
const fetching = useIsFetching() > 0
const status = healthQuery.isPending
? { tone: 'neutral' as const, label: '检测中' }
: healthQuery.isError
? { tone: 'danger' as const, label: '引擎离线' }
: { tone: 'success' as const, label: '引擎在线' }
useEffect(() => {
document.documentElement.dataset.scheme = scheme
window.localStorage.setItem(THEME_STORAGE_KEY, scheme)
}, [scheme])
return (
<div className="app-header">
<div className="app-header-brand">
<Brand name="TaskPilot" mark="TP" />
<Text className="app-header-tagline" size="xs" tone="muted">
本地任务调度 · Windows
</Text>
</div>
<Brand name="TaskPilot" mark="TP" />
<Space className="app-header-status" gap="xs" align="center">
<Tag tone={status.tone} dot>
{status.label}
</Tag>
<Text size="xs" tone="muted">
{healthQuery.data?.version
? `Rust 引擎 v${healthQuery.data.version}`
: '127.0.0.1:3000'}
</Text>
</Space>
<div className="app-header-actions">
<IconButton
label={scheme === 'dark' ? '切换到浅色' : '切换到深色'}
icon={<Icon name={scheme === 'dark' ? 'sun_line' : 'moon_line'} />}
onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')}
/>
<IconButton
label="刷新"
icon={<Icon name="lightning_line" />}
spinning={fetching}
onClick={() => void queryClient.invalidateQueries()}
/>
<IconButton
label="配置"
icon={<Icon name="settings_1_line" />}
onClick={() => toast('配置入口还在任务卡片里(uv / 镜像源 / 虚拟环境)')}
/>
<IconButton
label="退出"
icon={<Icon name="exit_line" />}
onClick={() => toast('本地单机版本没有登录,可以直接关掉窗口')}
/>
</div>
</div>
)
}
@@ -17,12 +17,25 @@ interface DirectoryPickerDialogProps {
initialPath: string
onSelect: (path: string) => void
onClose: () => void
/** directory:选目录(默认);file:选单个文件 */
mode?: 'directory' | 'file'
/** file 模式下只显示这些扩展名的文件,例如 ['.exe'];目录始终显示 */
fileExtensions?: string[]
/** 覆盖弹窗标题 */
title?: string
}
function isFilePath(value: string): boolean {
return /[^\\/]+\.[A-Za-z0-9]+$/.test(value.trim())
}
/**
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
*
* `mode="file"` 时同一套浏览逻辑也用来挑单个文件(例如 `uv.exe`):
* 目录可以继续进入,文件点击选中后点「使用此文件」。
*
* 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是
* `Input` / `Button` / `Empty` / `Notice`。
*/
@@ -31,9 +44,14 @@ export function DirectoryPickerDialog({
initialPath,
onSelect,
onClose,
mode = 'directory',
fileExtensions,
title,
}: DirectoryPickerDialogProps) {
const isFileMode = mode === 'file'
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [draftPath, setDraftPath] = useState('')
const [selectedFile, setSelectedFile] = useState<string | null>(null)
useEffect(() => {
if (!open) {
@@ -42,11 +60,17 @@ export function DirectoryPickerDialog({
const start = initialPath.trim()
setCurrentPath(start || null)
setDraftPath(start)
setSelectedFile(null)
}, [initialPath, open])
const listing = useQuery({
queryKey: ['directories', currentPath],
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
queryKey: ['directories', currentPath, isFileMode],
queryFn: ({ signal }) =>
listDirectories(
currentPath ?? undefined,
{ includeFiles: isFileMode },
signal,
),
enabled: open,
retry: false,
})
@@ -57,20 +81,30 @@ export function DirectoryPickerDialog({
}
}, [listing.data?.path])
const entries = listing.data?.entries ?? []
const entries = (listing.data?.entries ?? []).filter((entry) => {
// 老接口条目没有 is_dir 时按目录处理,行为与之前一致。
if (!isFileMode || entry.is_dir !== false || !fileExtensions?.length) {
return true
}
const lower = entry.name.toLowerCase()
return fileExtensions.some((extension) => lower.endsWith(extension))
})
const currentDirectory = listing.data?.path ?? null
const parent = listing.data?.parent ?? null
const typedFile = isFileMode && isFilePath(draftPath) ? draftPath.trim() : null
const chosen = isFileMode ? (selectedFile ?? typedFile) : currentDirectory
const navigate = (path: string | null) => {
setCurrentPath(path)
setDraftPath(path ?? '')
setSelectedFile(null)
}
return (
<Modal
open={open}
onClose={onClose}
title="选择工作目录"
title={title ?? (isFileMode ? '选择文件' : '选择工作目录')}
size="lg"
className="directory-picker-modal"
footer={
@@ -80,22 +114,22 @@ export function DirectoryPickerDialog({
将使用
</Text>
<Text size="sm" tone="sub" truncate>
{currentDirectory ?? '尚未选择目录'}
{chosen ?? (isFileMode ? '尚未选择文件' : '尚未选择目录')}
</Text>
</div>
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
disabled={!currentDirectory}
disabled={!chosen}
onClick={() => {
if (currentDirectory) {
onSelect(currentDirectory)
if (chosen) {
onSelect(chosen)
onClose()
}
}}
>
使用此目录
{isFileMode ? '使用此文件' : '使用此目录'}
</Button>
</>
}
@@ -148,26 +182,49 @@ export function DirectoryPickerDialog({
{!listing.isPending && !listing.isError && entries.length === 0 ? (
<Empty
text={
currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'
currentDirectory
? isFileMode
? fileExtensions?.length
? `该目录下没有 ${fileExtensions.join(' / ')} 文件`
: '该目录下没有文件'
: '该目录下没有子文件夹'
: '没有可用的盘符'
}
/>
) : null}
{entries.length > 0 ? (
<ul className="directory-list">
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
className="directory-row"
onClick={() => navigate(entry.path)}
title={entry.path}
>
<Icon className="directory-icon" name="folder_more_line" />
<span className="directory-name">{entry.name}</span>
</button>
</li>
))}
{entries.map((entry) => {
const file = entry.is_dir === false
const selected = file && entry.path === selectedFile
return (
<li key={entry.path}>
<button
type="button"
className={
selected
? 'directory-row directory-row-selected'
: 'directory-row'
}
onClick={() =>
file ? setSelectedFile(entry.path) : navigate(entry.path)
}
title={entry.path}
>
<Icon
className="directory-icon"
name={file ? 'box_line' : 'folder_more_line'}
/>
<span className="directory-name">{entry.name}</span>
{selected ? (
<Icon className="directory-check" name="check_line" />
) : null}
</button>
</li>
)
})}
</ul>
) : null}
+10 -9
View File
@@ -54,6 +54,8 @@ function linesToPackages(value: string): string[] {
*
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
*
* 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。
*/
export function PythonEnvsSection() {
const queryClient = useQueryClient()
@@ -149,15 +151,15 @@ export function PythonEnvsSection() {
return (
<>
<Card padding="md">
<Space justify="between" align="center" wrap>
<Space direction="column" gap="xs">
<div className="env-manager-head">
<Space direction="column" gap="xs" align="start">
<Text strong>虚拟环境</Text>
<Text size="xs" tone="muted">
预创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
用已下载的 Python 解释器创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
</Text>
</Space>
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
</Space>
</div>
</Card>
<Card padding="none">
@@ -182,9 +184,8 @@ export function PythonEnvsSection() {
</div>
) : envs.length === 0 ? (
<Empty
className="empty-state"
text="还没有预创建的环境"
action={<Button onClick={() => setCreateOpen(true)}>新建环境</Button>}
className="env-manager-empty"
text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。"
/>
) : (
<Table columns={ENV_COLUMNS} rows={rows} />
@@ -293,8 +294,8 @@ export function PythonEnvsSection() {
label="Python 版本(可选)"
hint={
(pythonsQuery.data?.length ?? 0) === 0
? '还没有下载 Python;先在上方「Python 版本」里下载,或选默认让 uv 自动下载'
: '列表来自 uv 下载到平台目录的版本;留空由 uv 决定'
? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定'
: '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定'
}
>
<Select
@@ -11,7 +11,6 @@ import {
Tag,
Text,
TextButton,
Title,
toast,
type TableColumn,
} from '@stash/ui'
@@ -105,7 +104,6 @@ export function PythonMirrorsSection() {
})
const mirrors = mirrorsQuery.data ?? []
const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null
const openCreate = () => {
setEditingMirror(null)
@@ -168,7 +166,7 @@ export function PythonMirrorsSection() {
<>
<Card padding="md">
<Space justify="between" align="center" wrap>
<Space direction="column" gap="xs">
<Space direction="column" gap="xs" align="start">
<Text strong>包索引镜像</Text>
<Text size="xs" tone="muted">
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
@@ -178,30 +176,6 @@ export function PythonMirrorsSection() {
</Space>
</Card>
<Card padding="md" className="mirror-summary">
<div className="mirror-summary-main">
<Text size="xs" tone="muted">
当前默认镜像
</Text>
<Title level={2}>{defaultMirror?.name ?? '未设置默认镜像'}</Title>
<Text className="mono" size="sm" tone="sub">
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
</Text>
</div>
<div className="mirror-summary-meta">
<Text size="xs" tone="muted">
运行时注入
</Text>
<Space gap="xs" wrap>
<Tag shape="rounded">PIP_INDEX_URL</Tag>
<Tag shape="rounded">UV_INDEX_URL</Tag>
{defaultMirror?.trusted_host ? (
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
) : null}
</Space>
</div>
</Card>
<Notice title="镜像只影响任务运行,不会自动安装依赖">
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
优先。修改后对下一次运行生效。
@@ -1,135 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Empty,
Input,
Result,
Space,
Spinner,
Table,
Text,
toast,
type TableColumn,
} from '@stash/ui'
import { useState } from 'react'
import {
getUvSettings,
installPython,
listPythons,
type ManagedPython,
} from '../api/client'
const PYTHON_COLUMNS: TableColumn[] = [
{ key: 'version', title: '版本', width: 160 },
{ key: 'path', title: '解释器路径', width: 480 },
]
const PYTHON_VERSION_HINT = '3.12'
/**
* 「虚拟环境」页的 Python 版本区块:列出 uv 下载到平台目录的解释器,并支持下载新版本。
*
* 下载位置由 `UV_PYTHON_INSTALL_DIR` 决定,默认指向程序目录下的 `python\`。
*/
export function PythonRuntimeSection() {
const queryClient = useQueryClient()
const [version, setVersion] = useState('')
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
})
const uvQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
const installMutation = useMutation({
mutationFn: () => installPython({ version: version.trim() }),
onSuccess: (pythons: ManagedPython[]) => {
setVersion('')
toast.success(`Python 已下载(当前 ${pythons.length} 个版本可用)`)
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
},
onError: (error) => toast.error(error.message),
})
const pythons = pythonsQuery.data ?? []
const rows = pythons.map((python) => ({
id: python.version,
version: <Text strong>{python.version}</Text>,
path: (
<code className="code-chip" title={python.path}>
{python.path}
</code>
),
}))
return (
<Card padding="md">
<Space direction="column" gap="md">
<Space direction="column" gap="xs">
<Text strong>Python 版本</Text>
<Text size="xs" tone="muted">
uv 下载的解释器放在{' '}
<Text className="mono">{uvQuery.data?.python_dir ?? 'python\\'}</Text>
(`UV_PYTHON_INSTALL_DIR` 默认指这里);新建环境时从已下载的版本里选。
</Text>
</Space>
<Space gap="xs" wrap align="center">
<Input
value={version}
onChange={(event) => setVersion(event.target.value)}
className="mono-input"
placeholder={PYTHON_VERSION_HINT}
aria-label="要下载的 Python 版本"
spellCheck={false}
/>
<Button
variant="secondary"
loading={installMutation.isPending}
onClick={() => {
if (!version.trim()) {
toast.error('请填写要下载的 Python 版本,例如 3.12')
return
}
installMutation.mutate()
}}
>
下载 Python
</Button>
{installMutation.isPending ? (
<Text size="xs" tone="muted">
正在下载,可能需要几十秒...
</Text>
) : null}
</Space>
{pythonsQuery.isError ? (
<Result
status="error"
title="Python 列表加载失败"
description={pythonsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void pythonsQuery.refetch()}
>
重试
</Button>
}
/>
) : pythonsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 Python 版本" />
</div>
) : pythons.length === 0 ? (
<Empty className="empty-state" text="还没有下载 Python 版本" />
) : (
<Table columns={PYTHON_COLUMNS} rows={rows} />
)}
</Space>
</Card>
)
}
-1
View File
@@ -53,7 +53,6 @@ const TRIGGER_FILTERS = [
{ value: '', label: '全部触发' },
{ value: 'schedule', label: '定时' },
{ value: 'manual', label: '手动' },
{ value: 'retry', label: '重试' },
]
/**
+8 -2
View File
@@ -30,14 +30,20 @@ export function RunStatusBadge({ status }: RunStatusBadgeProps) {
interface EnabledBadgeProps {
enabled: boolean
className?: string
}
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
export function EnabledBadge({ enabled, className }: EnabledBadgeProps) {
return (
<Tag
tone={enabled ? 'success' : 'neutral'}
dot
className={enabled ? 'status-enabled' : 'status-disabled'}
className={[
enabled ? 'status-enabled' : 'status-disabled',
className,
]
.filter(Boolean)
.join(' ')}
>
{enabled ? '活跃中' : '已暂停'}
</Tag>
+175
View File
@@ -0,0 +1,175 @@
import { Button, Divider, Modal, Space, Tag, Text } from '@stash/ui'
import type { ReactNode } from 'react'
import type { Task } from '../api/client'
import {
actionTypeLabel,
formatDateTime,
formatShortDateTime,
} from '../lib/format'
import { EnabledBadge } from './StatusBadge'
interface TaskDetailModalProps {
task: Task | null
nextRunAt?: string
onClose: () => void
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<Text size="xs" tone="muted">
{label}
</Text>
<div className="detail-value">{children}</div>
</div>
)
}
function envSummary(env: Record<string, string> | undefined): string {
const entries = Object.entries(env ?? {})
if (entries.length === 0) {
return '无'
}
return entries.map(([key, value]) => `${key}=${value}`).join('\n')
}
/**
* 任务详情:只读展示任务配置,入口是任务卡片上的「详情」。
*
* 和编辑表单分开:这里不做校验,也不会因为字段缺失而崩,方便核对线上实际配置。
*/
export function TaskDetailModal({
task,
nextRunAt,
onClose,
}: TaskDetailModalProps) {
return (
<Modal
open={task !== null}
onClose={onClose}
title="任务详情"
size="lg"
footer={
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
}
>
{task ? (
<Space direction="column" gap="md" align="stretch">
<Space direction="column" gap="xs" align="start">
<Space gap="xs" align="center" wrap>
<Text size="lg" strong>
{task.name}
</Text>
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
<EnabledBadge enabled={task.enabled} />
</Space>
<Text className="mono" size="xs" tone="muted">
{task.id}
</Text>
{task.description ? (
<Text size="sm" tone="sub">
{task.description}
</Text>
) : null}
</Space>
<Divider spacing="sm" />
<Text strong size="sm">
基本信息
</Text>
<div className="detail-grid">
<DetailRow label="Cron">
<code className="code-chip">{task.cron}</code>
</DetailRow>
<DetailRow label="时区">{task.timezone}</DetailRow>
<DetailRow label="下次触发">
{task.enabled
? nextRunAt
? formatShortDateTime(nextRunAt)
: '调度中…'
: '未调度'}
</DetailRow>
<DetailRow label="超时">{task.timeout_secs} 秒</DetailRow>
</div>
<Divider spacing="sm" />
<Text strong size="sm">
执行配置
</Text>
<div className="detail-grid">
{task.action.type === 'script' ? (
<>
<DetailRow label="工作目录">
{task.action.working_dir}
</DetailRow>
<DetailRow label="解释器">{task.action.interpreter}</DetailRow>
<DetailRow label="脚本">{task.action.script}</DetailRow>
<DetailRow label="运行参数">
{task.action.args.length > 0
? task.action.args.join(' ')
: '无'}
</DetailRow>
<DetailRow label="环境变量">
{envSummary(task.action.env)}
</DetailRow>
</>
) : null}
{task.action.type === 'http' ? (
<>
<DetailRow label="请求 URL">{task.action.url}</DetailRow>
<DetailRow label="请求方式">{task.action.method}</DetailRow>
<DetailRow label="期望状态码">
{task.action.expected_status.length > 0
? task.action.expected_status.join(', ')
: '2xx'}
</DetailRow>
<DetailRow label="请求 Header">
{envSummary(task.action.headers)}
</DetailRow>
<DetailRow label="Body">{task.action.body || '无'}</DetailRow>
</>
) : null}
{task.action.type === 'process' ? (
<>
<DetailRow label="可执行文件">
{task.action.executable}
</DetailRow>
<DetailRow label="工作目录">
{task.action.working_dir ?? '继承程序目录'}
</DetailRow>
<DetailRow label="运行参数">
{task.action.args.length > 0
? task.action.args.join(' ')
: '无'}
</DetailRow>
<DetailRow label="环境变量">
{envSummary(task.action.env)}
</DetailRow>
</>
) : null}
</div>
<Divider spacing="sm" />
<Text strong size="sm">
时间
</Text>
<div className="detail-grid">
<DetailRow label="创建时间">
{formatDateTime(task.created_at)}
</DetailRow>
<DetailRow label="更新时间">
{formatDateTime(task.updated_at)}
</DetailRow>
</div>
</Space>
) : null}
</Modal>
)
}
@@ -462,6 +462,21 @@ export function TaskEditorModal({
</FormField>
</div>
<FormField
label="任务说明"
htmlFor="task-description"
hint="一句话说明这个任务做什么,会显示在任务卡片和详情里"
error={errors.description?.message}
>
<Textarea
id="task-description"
{...register('description')}
rows={2}
placeholder="例如:定时请求核心 API,返回非 200 状态码时触发报警"
aria-invalid={Boolean(errors.description)}
/>
</FormField>
<div className="form-grid">
<FormField
label="Cron 表达式"
+601
View File
@@ -0,0 +1,601 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Empty,
FormField,
Icon,
Input,
Modal,
Notice,
RadioGroup,
Result,
Space,
Spinner,
Tabs,
Text,
TextButton,
toast,
} from '@stash/ui'
import { useState } from 'react'
import {
downloadUv,
getUvSettings,
installPython,
listPythons,
updateUvSettings,
type ManagedPython,
type UvSettings,
type UvSource,
} from '../api/client'
import { shortUvVersion } from '../lib/format'
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
const SOURCE_OPTIONS = [
{ value: 'managed', label: '程序目录 uv' },
{ value: 'custom', label: '指定路径' },
]
const PYTHON_VERSION_HINT = '3.12'
type UvTab = 'binary' | 'env' | 'python'
interface EnvRow {
name: string
value: string
}
function rowsFrom(
extraEnv: Record<string, string> | null | undefined,
envDefaults: Record<string, string> | null | undefined,
): EnvRow[] {
const rows = Object.entries(extraEnv ?? {}).map(([name, value]) => ({
name,
value,
}))
// 没显式配置的路径变量显示成默认值那一行(相对程序目录),用户能看见也能改值;
// 这两个变量决定解释器和缓存的位置,界面里不允许删除。
for (const [name, value] of Object.entries(envDefaults ?? {})) {
const configured = rows.some(
(row) => row.name.toUpperCase() === name.toUpperCase(),
)
if (!configured) {
rows.unshift({ name, value })
}
}
return rows
}
function envMap(rows: EnvRow[]): Record<string, string> {
const map: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name && !value) {
continue
}
map[name] = value
}
return map
}
/** `D:\tools\uv.exe` → `D:\tools`;拿不到目录时返回空串(让浏览器从盘符开始)。 */
function directoryOf(path: string): string {
const trimmed = path.trim()
const index = Math.max(
trimmed.lastIndexOf('\\'),
trimmed.lastIndexOf('/'),
)
if (index <= 0) {
return ''
}
const directory = trimmed.slice(0, index)
return directory.endsWith(':') ? `${directory}\\` : directory
}
interface UvSettingsModalProps {
open: boolean
onClose: () => void
}
/**
* uv 配置弹窗:三段配置用标签页切换——可执行文件 / `UV_*` 环境变量 / Python 解释器。
*
* - 来源二选一:程序目录(只读路径 + 缺失时「下载 uv」)或指定路径(可编辑 + 「浏览」挑 exe)。
* - 下载镜像也在这一步:uv 本体与解释器都从它派生;解释器还可以用
* `UV_PYTHON_INSTALL_MIRROR` 单独覆盖(它就在环境变量里)。
* - 环境变量里 `UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_MIRROR`
* 是 uv 的关键变量,只能改值、不能删除;其余 `UV_*` 变量照常增删。
* - 解释器由 uv 下载到 `UV_PYTHON_INSTALL_DIR`;虚拟环境弹窗只从这份列表里挑版本。
* - 顶部不再放状态汇总:版本、解释器数量、镜像地址在首页的 uv 卡片里已经有了。
*/
export function UvSettingsModal({ open, onClose }: UvSettingsModalProps) {
const queryClient = useQueryClient()
const settingsQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
// 解释器列表由 uv 自己列举;uv 不可用时接口会报错,先不请求。
const uvReady = Boolean(settingsQuery.data?.version)
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
enabled: uvReady,
})
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
const [draftMirror, setDraftMirror] = useState<string | null>(null)
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
const [pythonVersion, setPythonVersion] = useState('')
const [pickerOpen, setPickerOpen] = useState(false)
const [activeTab, setActiveTab] = useState<UvTab>('binary')
const settings = settingsQuery.data
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
const source = draftSource ?? settings?.source ?? 'managed'
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
const mirrorUrl = draftMirror ?? settings?.mirror_url ?? ''
const envRows =
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
const pythons = pythonsQuery.data ?? []
const managedPath = `${settings?.managed_dir ?? 'uv'}\\uv.exe`
const lockedNames = Object.keys(settings?.env_defaults ?? {}).map((name) =>
name.toUpperCase(),
)
const isLockedName = (name: string) =>
lockedNames.includes(name.trim().toUpperCase())
const resetDrafts = () => {
setDraftSource(null)
setDraftCustomPath(null)
setDraftMirror(null)
setDraftEnv(null)
}
const saveMutation = useMutation({
mutationFn: () => {
const extraEnv = envMap(envRows)
// 和默认值一样就不写死,删掉等于回到默认。
for (const [name, value] of Object.entries(settings?.env_defaults ?? {})) {
const configured = Object.keys(extraEnv).some(
(key) => key.toUpperCase() === name.toUpperCase(),
)
if (configured && extraEnv[name]?.trim() === value) {
delete extraEnv[name]
}
}
return updateUvSettings({
source,
custom_path: customPath.trim() || null,
extra_env: extraEnv,
mirror_url: mirrorUrl.trim() || null,
})
},
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
saved.version
? `uv 设置已保存(${shortUvVersion(saved.version)})`
: '已保存 uv 设置,但当前 uv 不可用',
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const downloadMutation = useMutation({
mutationFn: () => downloadUv(),
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
`已下载 ${shortUvVersion(saved.version) ?? 'uv'} 并切换到程序目录 uv`,
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const installMutation = useMutation({
mutationFn: () => installPython({ version: pythonVersion.trim() }),
onSuccess: (installed: ManagedPython[]) => {
setPythonVersion('')
queryClient.setQueryData(['managed-pythons'], installed)
toast.success(`Python 已下载(当前 ${installed.length} 个版本可用)`)
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
},
onError: (error) => toast.error(error.message),
})
const updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
setDraftEnv(
envRows.map((row, position) =>
position === index ? { ...row, ...patch } : row,
),
)
}
const submit = () => {
const seen = new Set<string>()
for (const row of envRows) {
const name = row.name.trim()
if (!name) {
if (row.value.trim()) {
toast.error('环境变量名不能为空')
return
}
continue
}
if (!name.toUpperCase().startsWith('UV_')) {
toast.error(`环境变量名 ${name} 需要以 UV_ 开头`)
return
}
const key = name.toUpperCase()
if (seen.has(key)) {
toast.error(`环境变量 ${name} 重复`)
return
}
seen.add(key)
}
// 两个路径变量决定解释器与缓存的位置,清空会让 uv 找不到目录。
for (const name of lockedNames) {
const row = envRows.find(
(candidate) => candidate.name.trim().toUpperCase() === name,
)
if (!row || !row.value.trim()) {
toast.error(`${name} 不能为空,它决定 uv 的 Python / 缓存目录`)
return
}
}
saveMutation.mutate()
}
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
return (
<Modal
open={open}
onClose={onClose}
title="uv 配置"
size="lg"
className="uv-settings-modal"
footer={
<>
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
<Button
type="submit"
form="uv-settings-form"
loading={saveMutation.isPending}
disabled={saveDisabled}
>
保存设置
</Button>
</>
}
>
{settingsQuery.isError ? (
<Result
status="error"
title="uv 设置加载失败"
description={settingsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void settingsQuery.refetch()}
>
重试
</Button>
}
/>
) : settingsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 uv 设置" />
<Text tone="sub">正在检测 uv...</Text>
</div>
) : (
<form
id="uv-settings-form"
className="uv-settings"
noValidate
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<Tabs
className="uv-tabs"
variant="line"
items={[
{ key: 'binary', label: 'uv 可执行文件' },
{ key: 'env', label: `环境变量(${envRows.length})` },
{ key: 'python', label: `Python 解释器(${pythons.length})` },
]}
activeKey={activeTab}
onChange={(key) => setActiveTab(key as UvTab)}
/>
{activeTab === 'binary' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="uv 可执行文件设置"
>
<RadioGroup
direction="row"
options={SOURCE_OPTIONS}
value={source}
onChange={(value) => setDraftSource(value as UvSource)}
/>
<div className="uv-path-row">
{source === 'managed' ? (
// 程序目录的 uv 是固定位置,只展示、不可编辑;只有「指定路径」能改。
<div className="uv-path-static" title={managedPath}>
<Icon name="lock_line" />
<span className="uv-path-static-text">{managedPath}</span>
</div>
) : (
<Input
value={customPath}
onChange={(event) => setDraftCustomPath(event.target.value)}
className="mono-input"
aria-label="uv 可执行文件"
placeholder="D:\\tools\\uv\\uv.exe"
spellCheck={false}
/>
)}
{source === 'managed' ? (
uvReady ? null : (
<Button
size="sm"
variant="secondary"
onClick={() => downloadMutation.mutate()}
loading={downloadMutation.isPending}
>
下载 uv
</Button>
)
) : (
<Button
size="sm"
variant="secondary"
onClick={() => setPickerOpen(true)}
>
浏览
</Button>
)}
</div>
<Text size="xs" tone="muted">
{source === 'managed'
? uvReady
? '程序目录里的 uv,随发布目录整体搬走。'
: '没装 uv 时可以从下面的镜像下载当前架构的版本,校验 sha256 后放进程序目录并自动切换。'
: '填 uv.exe 的完整路径,或点「浏览」选择;保存时会校验它能否运行。'}
</Text>
<FormField
label="下载镜像"
htmlFor="uv-mirror-url"
hint="GitHub Release 镜像根:uv 本体与 Python 解释器都从这里下载,留空用内置默认。解释器地址也可以用下面的 UV_PYTHON_INSTALL_MIRROR 单独覆盖。"
>
<Input
id="uv-mirror-url"
value={mirrorUrl}
onChange={(event) => setDraftMirror(event.target.value)}
className="mono-input"
placeholder="https://mirrors.ustc.edu.cn/github-release"
spellCheck={false}
/>
</FormField>
</section>
) : activeTab === 'env' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="UV 环境变量"
>
<div className="uv-panel-head">
<Text size="xs" tone="muted">
共 {envRows.length} 项
</Text>
<TextButton
onClick={() =>
setDraftEnv([...envRows, { name: '', value: '' }])
}
>
+ 添加变量
</TextButton>
</div>
<Text size="xs" tone="muted">
UV_PYTHON_INSTALL_DIR、UV_CACHE_DIR 决定解释器和缓存放在哪,
UV_PYTHON_INSTALL_MIRROR 决定解释器从哪下载;这三项只能改值、不能删除。
其余 UV_* 变量会注入到每一次 uv 调用,路径类可填相对程序目录的值。
</Text>
{envRows.length === 0 ? (
<Text size="sm" tone="muted">
没有额外环境变量
</Text>
) : (
<div className="uv-env-rows">
{envRows.map((row, index) => {
const locked = isLockedName(row.name)
const isMirror =
row.name.trim().toUpperCase() ===
'UV_PYTHON_INSTALL_MIRROR'
return (
<div className="uv-env-row" key={index}>
{locked ? (
<span
className="uv-env-locked"
title={
isMirror
? '解释器的下载源,不能删除;改值即可换镜像'
: '决定 uv 的 Python / 缓存目录,不能删除'
}
>
<Icon name="lock_line" />
<span className="uv-env-locked-name">{row.name}</span>
</span>
) : (
<Input
value={row.name}
onChange={(event) =>
updateEnvRow(index, { name: event.target.value })
}
className="mono-input"
placeholder="UV_HTTP_TIMEOUT"
spellCheck={false}
aria-label={`环境变量名 ${index + 1}`}
/>
)}
<Input
value={row.value}
onChange={(event) =>
updateEnvRow(index, { value: event.target.value })
}
className="mono-input"
placeholder={
locked && !isMirror
? '相对于程序目录,或绝对路径'
: '值'
}
spellCheck={false}
aria-label={`环境变量值 ${index + 1}`}
/>
{locked ? (
<Text size="xs" tone="muted">
固定
</Text>
) : (
<TextButton
tone="danger"
onClick={() =>
setDraftEnv(
envRows.filter(
(_, position) => position !== index,
),
)
}
>
删除
</TextButton>
)}
</div>
)
})}
</div>
)}
</section>
) : (
<section
className="uv-section"
role="tabpanel"
aria-label="Python 解释器设置"
>
<Text size="xs" tone="muted">
由 uv 下载,共 {pythons.length} 个版本。解释器放在{' '}
<Text className="mono" size="xs" tone="sub">
{settings?.python_dir ?? 'python\\'}
</Text>
(UV_PYTHON_INSTALL_DIR);新建虚拟环境时直接选这里的版本,不用重复下载。
</Text>
<Space gap="xs" align="center" wrap>
<Input
value={pythonVersion}
onChange={(event) => setPythonVersion(event.target.value)}
className="mono-input uv-version-input"
placeholder={PYTHON_VERSION_HINT}
aria-label="要下载的 Python 版本"
spellCheck={false}
/>
<Button
variant="secondary"
loading={installMutation.isPending}
disabled={!uvReady}
title={uvReady ? undefined : '请先安装或指定 uv'}
onClick={() => {
if (!pythonVersion.trim()) {
toast.error('请填写要下载的 Python 版本,例如 3.12')
return
}
installMutation.mutate()
}}
>
下载 Python
</Button>
{installMutation.isPending ? (
<Text size="xs" tone="muted">
正在下载,可能需要几十秒...
</Text>
) : null}
</Space>
{pythonsQuery.isError ? (
<Notice
tone="error"
title="解释器列表读取失败"
action={{
label: '重试',
onClick: () => void pythonsQuery.refetch(),
}}
>
{pythonsQuery.error.message}
</Notice>
) : pythonsQuery.isPending ? (
uvReady ? (
<div className="uv-inline-state">
<Spinner size="sm" label="正在读取解释器" />
<Text size="sm" tone="muted">
正在读取解释器...
</Text>
</div>
) : (
<Text size="sm" tone="muted">
先在上面安装或指定 uv,装好之后才能下载解释器。
</Text>
)
) : pythons.length === 0 ? (
<Empty
className="uv-empty"
text="还没有下载解释器。填一个版本号(例如 3.12)后点「下载 Python」。"
/>
) : (
<ul className="uv-python-list">
{pythons.map((python) => (
<li className="uv-python-item" key={python.version}>
<Text strong className="uv-python-version">
{python.version}
</Text>
<code
className="code-chip uv-python-path"
title={python.path}
>
{python.path}
</code>
</li>
))}
</ul>
)}
</section>
)}
</form>
)}
<DirectoryPickerDialog
open={pickerOpen}
initialPath={directoryOf(customPath)}
mode="file"
fileExtensions={['.exe']}
title="选择 uv 可执行文件"
onSelect={(path) => setDraftCustomPath(path)}
onClose={() => setPickerOpen(false)}
/>
</Modal>
)
}
@@ -1,335 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
FormField,
Icon,
IconButton,
Input,
Result,
Space,
Spinner,
Text,
TextButton,
toast,
} from '@stash/ui'
import { useState } from 'react'
import {
downloadUv,
getUvSettings,
updateUvSettings,
type UvSettings,
type UvSource,
} from '../api/client'
import { shortUvVersion } from '../lib/format'
const SOURCE_OPTIONS = [
{ value: 'managed', label: '程序目录 uv' },
{ value: 'custom', label: '指定路径' },
]
interface EnvRow {
name: string
value: string
}
function rowsFrom(
extraEnv: Record<string, string> | null | undefined,
envDefaults: Record<string, string> | null | undefined,
): EnvRow[] {
const rows = Object.entries(extraEnv ?? {}).map(([name, value]) => ({
name,
value,
}))
// 没显式配置的路径变量显示成默认值那一行(相对程序目录),用户能看见也能改;
// 删掉这行或保持默认值就回到默认。
for (const [name, value] of Object.entries(envDefaults ?? {})) {
const configured = rows.some(
(row) => row.name.toUpperCase() === name.toUpperCase(),
)
if (!configured) {
rows.unshift({ name, value })
}
}
return rows
}
function envMap(rows: EnvRow[]): Record<string, string> {
const map: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name && !value) {
continue
}
map[name] = value
}
return map
}
interface UvSettingsSectionProps {
onClose: () => void
}
/**
* uv 配置:可执行文件来源 + 环境变量。
*
* 不读系统 PATH:只用程序目录下的 `uv\uv.exe`(下载或自己放进来),或指定路径。
* 只在「uv 配置」弹窗里渲染,不常驻页面。
*/
export function UvSettingsSection({ onClose }: UvSettingsSectionProps) {
const queryClient = useQueryClient()
const settingsQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
const settings = settingsQuery.data
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
const source = draftSource ?? settings?.source ?? 'managed'
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
const envRows =
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
const resetDrafts = () => {
setDraftSource(null)
setDraftCustomPath(null)
setDraftEnv(null)
}
const saveMutation = useMutation({
mutationFn: () => {
const extraEnv = envMap(envRows)
// 和默认值一样就不写死,删掉等于回到默认。
for (const [name, value] of Object.entries(settings?.env_defaults ?? {})) {
const configured = Object.keys(extraEnv).some(
(key) => key.toUpperCase() === name.toUpperCase(),
)
if (configured && extraEnv[name]?.trim() === value) {
delete extraEnv[name]
}
}
return updateUvSettings({
source,
custom_path: customPath.trim() || null,
extra_env: extraEnv,
})
},
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
saved.version
? `uv 设置已保存(${shortUvVersion(saved.version)})`
: '已保存 uv 设置,但当前 uv 不可用',
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const downloadMutation = useMutation({
mutationFn: () => downloadUv(),
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
`已下载 ${shortUvVersion(saved.version) ?? 'uv'} 并切换到程序目录 uv`,
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
setDraftEnv(
envRows.map((row, position) =>
position === index ? { ...row, ...patch } : row,
),
)
}
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
return (
<Space direction="column" gap="lg">
{settingsQuery.isError ? (
<Result
status="error"
title="uv 设置加载失败"
description={settingsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void settingsQuery.refetch()}
>
重试
</Button>
}
/>
) : settingsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 uv 设置" />
<Text tone="sub">正在检测 uv...</Text>
</div>
) : (
<>
<div className="form-section">
<div className="form-section-head">
<Space gap="xs" align="center">
<Icon name="chip_line" className="form-section-icon" />
<Text strong>uv 可执行文件</Text>
</Space>
<Text size="xs" tone="muted">
必选 1 项
</Text>
</div>
<div className="chip-row">
{SOURCE_OPTIONS.map((option) => (
<Button
key={option.value}
size="sm"
shape="pill"
variant={source === option.value ? 'primary' : 'secondary'}
onClick={() => setDraftSource(option.value as UvSource)}
>
{option.label}
</Button>
))}
</div>
<Text className="mono" size="xs" tone="sub">
{settings?.version
? `当前 ${shortUvVersion(settings.version)} · ${settings.executable}`
: '未检测到可用的 uv'}
</Text>
{source === 'managed' ? (
<Space gap="xs" align="center" wrap>
<Text
className="mono"
size="xs"
tone="muted"
title={`${settings?.managed_dir ?? 'uv'}\\uv.exe`}
>
{settings?.managed_dir ?? '程序目录'}\uv.exe
</Text>
{settings?.version ? null : (
<Button
size="sm"
variant="secondary"
onClick={() => downloadMutation.mutate()}
loading={downloadMutation.isPending}
>
下载 uv
</Button>
)}
</Space>
) : (
<FormField
label="uv 可执行文件"
htmlFor="uv-custom-path"
hint="填 uv.exe 的完整路径,例如 C:\\Users\\你\\.local\\bin\\uv.exe;保存时会校验它能否运行"
>
<Input
id="uv-custom-path"
value={customPath}
onChange={(event) => setDraftCustomPath(event.target.value)}
className="mono-input"
placeholder="D:\\tools\\uv\\uv.exe"
spellCheck={false}
/>
</FormField>
)}
</div>
<div className="form-section">
<div className="form-section-head">
<Space gap="xs" align="center">
<Icon name="box_line" className="form-section-icon" />
<Text strong>环境变量</Text>
</Space>
<Text size="xs" tone="muted">
可选 · 已设 {envRows.length} 项
</Text>
</div>
<Space justify="between" align="center" wrap>
<Text size="xs" tone="muted">
UV_* 变量(注入到每一次 uv 调用;路径类可填相对程序目录的路径,也可填绝对路径)
</Text>
<TextButton
onClick={() =>
setDraftEnv([...envRows, { name: '', value: '' }])
}
>
+ 添加变量
</TextButton>
</Space>
{envRows.length === 0 ? (
<Text size="sm" tone="muted">
没有额外环境变量
</Text>
) : (
<div className="uv-env-rows">
{envRows.map((row, index) => (
<div className="uv-env-row" key={index}>
<Input
value={row.name}
onChange={(event) =>
updateEnvRow(index, { name: event.target.value })
}
className="mono-input"
placeholder="UV_CACHE_DIR"
spellCheck={false}
aria-label={`环境变量名 ${index + 1}`}
/>
<Input
value={row.value}
onChange={(event) =>
updateEnvRow(index, { value: event.target.value })
}
className="mono-input"
placeholder="D:\\uv-cache"
spellCheck={false}
aria-label={`环境变量值 ${index + 1}`}
/>
<TextButton
tone="danger"
onClick={() =>
setDraftEnv(
envRows.filter((_, position) => position !== index),
)
}
>
删除
</TextButton>
</div>
))}
</div>
)}
</div>
</>
)}
<div className="form-actions">
<IconButton
label="关闭"
icon={<Icon name="close_line" />}
variant="soft"
onClick={onClose}
/>
<Button
block
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={saveDisabled}
>
保存
</Button>
</div>
</Space>
)
}
+8 -5
View File
@@ -84,6 +84,7 @@ export function detectScriptLanguage(interpreter: string): ScriptLanguage {
export interface TaskFormValues {
id: string
name: string
description: string
cron: string
timezone: string
timeout_secs: number
@@ -142,6 +143,10 @@ export const taskFormSchema = z
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
description: z
.string()
.trim()
.max(500, '任务说明不能超过 500 个字符'),
cron: z
.string()
.trim()
@@ -227,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
description: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
@@ -261,6 +267,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
const values = createEmptyTaskFormValues()
values.id = task.id
values.name = task.name
values.description = task.description ?? ''
values.cron = task.cron
values.timezone = task.timezone
values.timeout_secs = task.timeout_secs
@@ -359,15 +366,11 @@ export function valuesToUpdateRequest(
): UpdateTaskRequest {
return {
name: values.name.trim(),
description: values.description.trim(),
cron: values.cron.trim(),
timezone: values.timezone.trim(),
timeout_secs: values.timeout_secs,
enabled: values.enabled,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: valuesToAction(values),
}
}
+256 -211
View File
@@ -11,7 +11,6 @@ import {
Empty,
Modal,
Notice,
Pagination,
Result,
SearchField,
Select,
@@ -33,6 +32,7 @@ import {
fetchStats,
getUvSettings,
listManagedEnvs,
listPythons,
listPythonMirrors,
listTasks,
runTask,
@@ -45,11 +45,11 @@ import {
import { ConfirmDialog } from '../components/ConfirmDialog'
import { PythonEnvsSection } from '../components/PythonEnvsSection'
import { PythonMirrorsSection } from '../components/PythonMirrorsSection'
import { PythonRuntimeSection } from '../components/PythonRuntimeSection'
import { RunLogDrawer } from '../components/RunLogDrawer'
import { EnabledBadge } from '../components/StatusBadge'
import { TaskDetailModal } from '../components/TaskDetailModal'
import { TaskEditorModal } from '../components/TaskEditorModal'
import { UvSettingsSection } from '../components/UvSettingsSection'
import { UvSettingsModal } from '../components/UvSettingsModal'
import {
actionTypeLabel,
formatShortDateTime,
@@ -62,7 +62,7 @@ import {
valuesToUpdateRequest,
} from '../lib/taskForm'
const PAGE_SIZE = 20
const MAX_TASKS = 100
const ENABLED_OPTIONS = [
{ value: '', label: '全部状态' },
@@ -77,12 +77,12 @@ const ACTION_TYPE_OPTIONS = [
{ value: 'process', label: '进程' },
]
/// 中间栏按任务类型分组,和顶部筛选配合使用。
const TASK_GROUPS: Array<{ type: TaskActionType; label: string }> = [
{ type: 'script', label: '脚本' },
{ type: 'http', label: 'HTTP 请求' },
{ type: 'process', label: '二进制程序' },
]
/// 列表按任务类型排在一起,类型在卡片里用标签标出。
const TYPE_ORDER: Record<TaskActionType, number> = {
script: 0,
http: 1,
process: 2,
}
export function TaskDashboard() {
const queryClient = useQueryClient()
@@ -90,14 +90,13 @@ export function TaskDashboard() {
const q = searchParams.get('q') ?? ''
const enabledValue = searchParams.get('enabled')
const actionTypeValue = searchParams.get('action_type')
const page = Math.max(Number(searchParams.get('page') ?? 1) || 1, 1)
const [editorOpen, setEditorOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
const [logTask, setLogTask] = useState<Task | null>(null)
const [detailTask, setDetailTask] = useState<Task | null>(null)
const [initialRunId, setInitialRunId] = useState<number | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [uvSettingsOpen, setUvSettingsOpen] = useState(false)
const [mirrorSettingsOpen, setMirrorSettingsOpen] = useState(false)
const [envManagerOpen, setEnvManagerOpen] = useState(false)
@@ -118,8 +117,8 @@ export function TaskDashboard() {
q: q || undefined,
enabled,
action_type: actionType,
page,
page_size: PAGE_SIZE,
page: 1,
page_size: MAX_TASKS,
}
const healthQuery = useQuery({
@@ -154,6 +153,11 @@ export function TaskDashboard() {
queryKey: ['managed-envs'],
queryFn: ({ signal }) => listManagedEnvs(signal),
})
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
enabled: Boolean(uvQuery.data?.version),
})
const nextRuns = new Map(
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
@@ -162,6 +166,7 @@ export function TaskDashboard() {
mirrorsQuery.data?.find((mirror) => mirror.is_default) ?? null
const uv = uvQuery.data
const managedEnvs = envsQuery.data ?? []
const pythonCount = pythonsQuery.data?.length ?? 0
const saveMutation = useMutation({
mutationFn: ({
@@ -227,19 +232,25 @@ export function TaskDashboard() {
if (nextQuery === q) {
return
}
updateFilters({ q: nextQuery || undefined, page: '1' })
updateFilters({ q: nextQuery || undefined })
// updateFilters 依赖当前 searchParams,每次渲染都是新的闭包;这里只跟随防抖后的输入。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery])
const tasks = tasksQuery.data?.items ?? []
const total = tasksQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const groups = TASK_GROUPS.map((group) => ({
...group,
tasks: tasks.filter((task) => task.action.type === group.type),
})).filter((group) => group.tasks.length > 0)
const nearestRun = [...nextRuns.values()].sort()[0]
const sortedTasks = [...tasks].sort(
(left, right) =>
TYPE_ORDER[left.action.type] - TYPE_ORDER[right.action.type],
)
// 最近一次触发:取所有已注册调度里最早的 next_run_at,连带任务名一起显示
const nearest = (scheduleQuery.data ?? [])
.filter((entry): entry is { task_id: string; next_run_at: string } =>
Boolean(entry.next_run_at),
)
.sort((left, right) => left.next_run_at.localeCompare(right.next_run_at))[0]
const nearestTask = nearest
? tasks.find((task) => task.id === nearest.task_id)
: undefined
function updateFilters(changes: Record<string, string | undefined>) {
const next = new URLSearchParams(searchParams)
@@ -263,21 +274,6 @@ export function TaskDashboard() {
setEditorOpen(true)
}
const refreshAll = async () => {
if (refreshing) {
return
}
setRefreshing(true)
try {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
queryClient.invalidateQueries({ queryKey: ['health'] }),
])
} finally {
setRefreshing(false)
}
}
return (
<div className="dashboard">
<div className="dashboard-head">
@@ -295,27 +291,43 @@ export function TaskDashboard() {
{/* 左栏:用户 / 概览 / 调度 */}
<aside className="dashboard-column dashboard-left" aria-label="概览">
<Card padding="md">
<div className="user-card">
<div className="user-avatar">
<Avatar fallback="本" size="lg" tone="primary" verified />
<span className="user-online" />
</div>
<div className="user-meta">
<Space gap="xs" align="center" wrap>
<Text strong>本地用户</Text>
<Tag tone="success" shape="rounded">
单机
</Tag>
</Space>
<Text size="xs" tone="muted">
永远本地模式
<span className="user-dot" />
<div className="user-profile">
<Avatar
className="user-profile-avatar"
fallback="本"
size="xl"
tone="primary"
verified
/>
<div className="user-profile-name-row">
<Text size="lg" strong>
本地用户
</Text>
<span className="user-profile-status" aria-hidden="true" />
</div>
<Tag shape="pill" className="user-profile-role">
管理员
</Tag>
<p className="user-profile-bio">
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
</p>
<Text size="xs" tone="muted" className="user-profile-meta">
<span
className={
healthQuery.isError
? 'user-profile-engine-dot is-offline'
: 'user-profile-engine-dot'
}
aria-hidden="true"
/>
引擎
{healthQuery.isPending
? '检测中'
: healthQuery.isError
? '离线'
: '在线'}
</Text>
</div>
<p className="user-bio">
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
</p>
</Card>
<Card padding="md">
@@ -339,42 +351,36 @@ export function TaskDashboard() {
loading={statsQuery.isPending}
/>
<DashMetric
label="Rust 引擎"
value={
healthQuery.isPending
? '...'
: healthQuery.isError
? '离线'
: '在线'
}
loading={false}
danger={healthQuery.isError}
label="HTTP 请求"
value={statsQuery.data?.http}
loading={statsQuery.isPending}
/>
<DashMetric
label="二进制程序"
value={statsQuery.data?.process}
loading={statsQuery.isPending}
/>
</div>
</Card>
<Card padding="md">
<Text strong size="sm">
调度
最近触发
</Text>
<div className="dash-kv">
<Text size="xs" tone="muted">
已启用 / 已停用
{nearest ? (
<div className="dash-inline">
<Text className="dash-inline-name" size="sm" truncate>
{nearestTask?.name ?? nearest.task_id}
</Text>
<Text className="dash-inline-time" size="sm">
{formatShortDateTime(nearest.next_run_at)}
</Text>
</div>
) : (
<Text className="dash-inline-empty" size="sm" tone="muted">
暂无
</Text>
<Text className="mono" size="sm">
{statsQuery.data
? `${statsQuery.data.enabled} / ${statsQuery.data.total - statsQuery.data.enabled}`
: '--'}
</Text>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
最近触发
</Text>
<Text size="sm">
{nearestRun ? formatShortDateTime(nearestRun) : '暂无'}
</Text>
</div>
)}
</Card>
</aside>
@@ -388,7 +394,7 @@ export function TaskDashboard() {
onChange={setQueryDraft}
placeholder="搜索任务名称或 ID"
size="md"
maxWidth={360}
maxWidth={420}
/>
<Space gap="xs" wrap align="center">
<Select
@@ -398,7 +404,6 @@ export function TaskDashboard() {
onChange={(value) =>
updateFilters({
enabled: value || undefined,
page: '1',
})
}
/>
@@ -409,21 +414,9 @@ export function TaskDashboard() {
onChange={(value) =>
updateFilters({
action_type: value || undefined,
page: '1',
})
}
/>
{hasFilters ? (
<TextButton onClick={() => setSearchParams({})}>
清除筛选
</TextButton>
) : null}
<TextButton
onClick={() => void refreshAll()}
disabled={refreshing}
>
刷新
</TextButton>
</Space>
</Space>
</Card>
@@ -486,58 +479,29 @@ export function TaskDashboard() {
/>
</Card>
) : (
<>
{groups.map((group) => (
<Card key={group.type} padding="md">
<Space justify="between" align="center">
<Space gap="xs" align="center">
<Text strong>{group.label}</Text>
<Tag shape="rounded">{group.tasks.length}</Tag>
</Space>
<Text size="xs" tone="muted">
{actionTypeLabel(group.type)}
</Text>
</Space>
<div className="task-grid">
{group.tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
nextRunAt={nextRuns.get(task.id)}
running={
runMutation.isPending &&
runMutation.variables?.id === task.id
}
toggling={
toggleMutation.isPending &&
toggleMutation.variables?.id === task.id
}
onRun={() => runMutation.mutate(task)}
onLogs={() => {
setLogTask(task)
setInitialRunId(null)
}}
onEdit={() => openEdit(task)}
onToggle={() => toggleMutation.mutate(task)}
onDelete={() => setDeleteTarget(task)}
/>
))}
</div>
</Card>
))}
<footer className="pagination-bar">
<Text size="sm" tone="sub">
共 {total} 个任务,第 {page} / {totalPages} 页
</Text>
<Pagination
page={page}
total={total}
pageSize={PAGE_SIZE}
onChange={(next) => updateFilters({ page: String(next) })}
/>
</footer>
</>
sortedTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
nextRunAt={nextRuns.get(task.id)}
running={
runMutation.isPending && runMutation.variables?.id === task.id
}
toggling={
toggleMutation.isPending &&
toggleMutation.variables?.id === task.id
}
onRun={() => runMutation.mutate(task)}
onLogs={() => {
setLogTask(task)
setInitialRunId(null)
}}
onEdit={() => openEdit(task)}
onToggle={() => toggleMutation.mutate(task)}
onDelete={() => setDeleteTarget(task)}
onDetails={() => setDetailTask(task)}
/>
))
)}
</section>
@@ -548,17 +512,33 @@ export function TaskDashboard() {
<Text strong size="sm">
uv
</Text>
<TextButton onClick={() => setUvSettingsOpen(true)}>
<TextButton label="配置 uv" onClick={() => setUvSettingsOpen(true)}>
配置
</TextButton>
</Space>
<div className="dash-kv">
<Text size="xs" tone="muted">
版本
</Text>
<Text className="mono" size="sm">
{shortUvVersion(uv?.version) ?? '未检测到'}
</Text>
<div className="dash-inline-kv">
<span className="dash-inline-item">
<Text size="xs" tone="muted">
版本
</Text>
<Text className="mono" size="sm">
{shortUvVersion(uv?.version) ?? '未检测到'}
</Text>
</span>
<span className="dash-inline-item">
<Text size="xs" tone="muted">
Python 解释器
</Text>
<Text size="sm">
{uv?.version
? pythonsQuery.isPending
? '读取中'
: pythonCount === 0
? '还没有下载'
: `${pythonCount} 个版本`
: '需要先安装 uv'}
</Text>
</span>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
@@ -576,6 +556,19 @@ export function TaskDashboard() {
{uv?.python_dir ?? 'python'}
</Text>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
下载镜像
</Text>
<Text
className="mono"
size="xs"
tone="sub"
title={uv?.mirror_url ?? undefined}
>
{uv?.mirror_url ?? '默认'}
</Text>
</div>
</Card>
<Card padding="md">
@@ -583,17 +576,38 @@ export function TaskDashboard() {
<Text strong size="sm">
镜像源
</Text>
<TextButton onClick={() => setMirrorSettingsOpen(true)}>
<TextButton
label="配置镜像源"
onClick={() => setMirrorSettingsOpen(true)}
>
配置
</TextButton>
</Space>
<div className="dash-kv">
<div className="mirror-card-summary">
<Text size="xs" tone="muted">
当前默认镜像
</Text>
<Text className="mono" size="xs" tone="sub">
{defaultMirror?.index_url ?? '未设置'}
<Text strong size="sm" truncate>
{defaultMirror?.name ?? '未设置默认镜像'}
</Text>
<Text
className="mono mirror-card-url"
size="xs"
tone="sub"
title={defaultMirror?.index_url ?? undefined}
>
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
</Text>
<Text size="xs" tone="muted" className="mirror-card-inject">
运行时注入
</Text>
<Space gap="xs" wrap>
<Tag shape="rounded">PIP_INDEX_URL</Tag>
<Tag shape="rounded">UV_INDEX_URL</Tag>
{defaultMirror?.trusted_host ? (
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
) : null}
</Space>
</div>
</Card>
@@ -627,16 +641,12 @@ export function TaskDashboard() {
</Card>
</aside>
{/* 配置弹窗:uv 与镜像源分开,各自一个弹窗;关闭时不渲染内容,避免重复表单 */}
{/* 配置弹窗:uv(含解释器下载)与镜像源分开,各自一个弹窗 */}
{uvSettingsOpen ? (
<Modal
<UvSettingsModal
open
onClose={() => setUvSettingsOpen(false)}
title="uv 配置"
size="lg"
>
<UvSettingsSection onClose={() => setUvSettingsOpen(false)} />
</Modal>
/>
) : null}
{mirrorSettingsOpen ? (
@@ -645,6 +655,7 @@ export function TaskDashboard() {
onClose={() => setMirrorSettingsOpen(false)}
title="镜像源配置"
size="lg"
className="mirror-settings-modal"
footer={
<Button
variant="secondary"
@@ -673,10 +684,7 @@ export function TaskDashboard() {
</Button>
}
>
<div className="modal-stack">
<PythonRuntimeSection />
<PythonEnvsSection />
</div>
<PythonEnvsSection />
</Modal>
) : null}
@@ -694,6 +702,12 @@ export function TaskDashboard() {
}}
/>
<TaskDetailModal
task={detailTask}
nextRunAt={detailTask ? nextRuns.get(detailTask.id) : undefined}
onClose={() => setDetailTask(null)}
/>
<RunLogDrawer
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
open={Boolean(logTask)}
@@ -729,16 +743,15 @@ interface DashMetricProps {
label: string
value: number | string | undefined
loading: boolean
danger?: boolean
}
function DashMetric({ label, value, loading, danger = false }: DashMetricProps) {
function DashMetric({ label, value, loading }: DashMetricProps) {
return (
<div className="dash-metric">
<Text size="xs" tone="muted">
{label}
</Text>
<span className={danger ? 'dash-metric-value dash-metric-danger' : 'dash-metric-value'}>
<span className="dash-metric-value">
{loading ? '...' : (value ?? '--')}
</span>
</div>
@@ -755,9 +768,10 @@ interface TaskItemProps {
onEdit: () => void
onToggle: () => void
onDelete: () => void
onDetails: () => void
}
function TaskItem({
function TaskCard({
task,
nextRunAt,
running,
@@ -767,51 +781,82 @@ function TaskItem({
onEdit,
onToggle,
onDelete,
onDetails,
}: TaskItemProps) {
const mark = task.name.trim().charAt(0).toUpperCase() || 'T'
return (
<div className="task-item">
<div className="task-item-main">
<span className={`task-item-mark task-item-mark-${task.action.type}`}>
{mark}
</span>
<div className="task-item-text">
<Card padding="sm">
<div className="task-card-head">
<EnabledBadge
enabled={task.enabled}
className="task-card-status-badge"
/>
<TextButton className="task-card-detail" onClick={onDetails}>
详情
</TextButton>
</div>
<div className="task-card">
<div className="task-card-main">
<Space gap="xs" align="center" wrap>
<Text strong>{task.name}</Text>
<EnabledBadge enabled={task.enabled} />
</Space>
<Text className="task-id" size="xs" tone="muted">
{task.id}
</Text>
<Space gap="xs" align="center" wrap>
<code className="code-chip">{task.cron}</code>
<Text size="xs" tone="muted">
{task.enabled
? nextRunAt
? `下次 ${formatShortDateTime(nextRunAt)}`
: '调度中…'
: '未调度'}
<Text className="task-card-title" size="lg" strong truncate>
{task.name}
</Text>
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
</Space>
<Text
className="task-card-desc"
size="xs"
tone="muted"
truncate
title={task.description.trim() || taskTarget(task)}
>
{task.description.trim() || taskTarget(task)}
</Text>
</div>
<div className="task-card-plan">
<code className="code-chip">{task.cron}</code>
<span className="task-card-next">
{task.enabled
? nextRunAt
? `下次 ${formatShortDateTime(nextRunAt)}`
: '调度中…'
: '未调度'}
</span>
</div>
<div className="task-card-side">
<Space className="task-card-actions" gap="xs" wrap justify="end">
<TextButton disabled={running} title="立即运行" onClick={onRun}>
{running ? '提交中' : '运行'}
</TextButton>
<TextButton onClick={onLogs}>日志</TextButton>
<TextButton onClick={onEdit}>编辑</TextButton>
<TextButton disabled={toggling} onClick={onToggle}>
{task.enabled ? '停用' : '启用'}
</TextButton>
<TextButton tone="danger" onClick={onDelete}>
删除
</TextButton>
</Space>
</div>
</div>
<Space className="task-item-actions" gap="xs" wrap justify="end">
<TextButton disabled={running} title="立即运行" onClick={onRun}>
{running ? '提交中' : '运行'}
</TextButton>
<TextButton onClick={onLogs}>日志</TextButton>
<TextButton onClick={onEdit}>编辑</TextButton>
<TextButton disabled={toggling} onClick={onToggle}>
{task.enabled ? '停用' : '启用'}
</TextButton>
<TextButton tone="danger" onClick={onDelete}>
删除
</TextButton>
</Space>
</div>
</Card>
)
}
/** 卡片第二行的一句话说明:任务标识 + 它实际执行什么。 */
function taskTarget(task: Task): string {
const action = task.action
if (action.type === 'http') {
return `${action.method} ${action.url}`
}
if (action.type === 'process') {
return action.executable
}
const directory = action.working_dir.replace(/[\\/]+$/, '')
return directory ? `${directory}\\${action.script}` : action.script
}
function isActionType(value: string | null): value is TaskActionType {
return value === 'script' || value === 'http' || value === 'process'
}