feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验 - uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程 - 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包 - 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用 - 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗 - 发布包构建后去掉工作区继承的低完整性标签 - 修复调度集成测试并发时误取 skipped 运行的问题
This commit is contained in:
+318
-2
@@ -48,10 +48,9 @@
|
||||
|
||||
.page-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
@@ -59,6 +58,7 @@
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
|
||||
@@ -1203,3 +1203,319 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.uv-env-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.uv-env-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 260px) minmax(240px, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.uv-env-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.env-packages-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* ---------- 顶部导航(三栏工作台) ---------- */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.app-header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-header-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---------- 三栏工作台 ---------- */
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 16px 16px 40px;
|
||||
}
|
||||
|
||||
.dashboard-head {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 4px 0;
|
||||
}
|
||||
|
||||
.dashboard-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dash-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--su-bg-subtle);
|
||||
}
|
||||
|
||||
.dash-metric-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-metric-danger {
|
||||
color: var(--su-color-danger);
|
||||
}
|
||||
|
||||
.dash-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dash-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.dash-list-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dash-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-kv > * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.task-item-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--su-bg-card);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-item-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-item-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-right {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-right > * {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.dashboard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.task-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 左栏用户卡片 ---------- */
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
.user-bio {
|
||||
margin: 12px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--su-border);
|
||||
color: var(--su-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 弹窗里的多个区块 */
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-section-icon {
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+332
-5
@@ -46,9 +46,58 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
|
||||
interface FetchMockOptions {
|
||||
directories?: (path: string | null) => Response
|
||||
envs?: Array<{
|
||||
name: string
|
||||
path: string
|
||||
python_version: string | null
|
||||
}>
|
||||
envPackages?: Record<string, Array<{ name: string; version: string }>>
|
||||
pythons?: Array<{ version: string; path: string }>
|
||||
}
|
||||
|
||||
function uvSettingsPayload(
|
||||
source: string,
|
||||
customPath: string | null,
|
||||
managedVersion: string | null = null,
|
||||
) {
|
||||
return {
|
||||
source,
|
||||
custom_path: customPath,
|
||||
managed_dir: 'D:\\TaskPilot\\uv',
|
||||
executable:
|
||||
source === 'managed'
|
||||
? 'D:\\TaskPilot\\uv\\uv.exe'
|
||||
: (customPath ?? 'C:\\tools\\uv\\uv.exe'),
|
||||
version: source === 'managed' ? managedVersion : 'uv 0.9.0',
|
||||
candidates: [
|
||||
{
|
||||
source: 'managed',
|
||||
path: 'D:\\TaskPilot\\uv\\uv.exe',
|
||||
available: managedVersion !== null,
|
||||
version: managedVersion,
|
||||
},
|
||||
...(customPath
|
||||
? [
|
||||
{
|
||||
source: 'custom',
|
||||
path: customPath,
|
||||
available: true,
|
||||
version: 'uv 0.9.0',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createFetchMock(options: FetchMockOptions = {}) {
|
||||
let uvSource = 'managed'
|
||||
let uvCustomPath: string | null = null
|
||||
let uvManagedVersion: string | null = null
|
||||
let managedEnvs = options.envs ?? []
|
||||
let managedPythons = options.pythons ?? []
|
||||
const managedPackages = options.envPackages ?? {}
|
||||
|
||||
return vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
@@ -97,6 +146,69 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/pythons') && init?.method === 'POST') {
|
||||
const request = JSON.parse(String(init.body)) as { version: string }
|
||||
const path = `D:\\TaskPilot\\python\\cpython-${request.version}-windows-x86_64-none\\python.exe`
|
||||
if (!managedPythons.some((item) => item.version === request.version)) {
|
||||
managedPythons = [
|
||||
...managedPythons,
|
||||
{ version: request.version, path },
|
||||
]
|
||||
}
|
||||
return jsonResponse(managedPythons)
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/pythons')) {
|
||||
return jsonResponse(managedPythons)
|
||||
}
|
||||
if (url.includes('/api/v1/python/envs/') && url.endsWith('/packages')) {
|
||||
const name = decodeURIComponent(
|
||||
url.split('/api/v1/python/envs/')[1].replace('/packages', ''),
|
||||
)
|
||||
return jsonResponse(managedPackages[name] ?? [])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/envs') && init?.method === 'POST') {
|
||||
const request = JSON.parse(String(init.body)) as {
|
||||
name: string
|
||||
python_version?: string | null
|
||||
}
|
||||
const created = {
|
||||
name: request.name,
|
||||
path: `D:\\TaskPilot\\data\\envs\\${request.name}`,
|
||||
python_version: request.python_version ?? '3.11.11',
|
||||
}
|
||||
managedEnvs = [...managedEnvs, created]
|
||||
return jsonResponse(created, 201)
|
||||
}
|
||||
if (url.includes('/api/v1/python/envs/') && init?.method === 'DELETE') {
|
||||
const name = decodeURIComponent(
|
||||
url.split('/api/v1/python/envs/')[1],
|
||||
)
|
||||
managedEnvs = managedEnvs.filter((env) => env.name !== name)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/envs')) {
|
||||
return jsonResponse(managedEnvs)
|
||||
}
|
||||
if (url.endsWith('/api/v1/uv/download')) {
|
||||
uvSource = 'managed'
|
||||
uvManagedVersion = 'uv 0.9.0'
|
||||
return jsonResponse(uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion))
|
||||
}
|
||||
if (url.endsWith('/api/v1/uv')) {
|
||||
const request = init?.body
|
||||
? (JSON.parse(String(init.body)) as {
|
||||
source: string
|
||||
custom_path?: string | null
|
||||
})
|
||||
: null
|
||||
if (request) {
|
||||
uvSource = request.source
|
||||
uvCustomPath = request.custom_path ?? null
|
||||
}
|
||||
return jsonResponse(
|
||||
uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion),
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -106,6 +218,21 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/venv/create')) {
|
||||
return jsonResponse({
|
||||
status: 'linked',
|
||||
interpreter_kind: 'python',
|
||||
venv_path: 'D:\\Projects\\uvproject\\.venv',
|
||||
display_path: '.venv\\Scripts\\python.exe',
|
||||
source: 'working_directory',
|
||||
message: '已创建虚拟环境',
|
||||
uv_available: true,
|
||||
has_pyproject: true,
|
||||
has_requirements: false,
|
||||
requirements_files: [],
|
||||
can_create: false,
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/venv')) {
|
||||
const probe = JSON.parse(String(init?.body)) as {
|
||||
working_dir: string
|
||||
@@ -122,7 +249,7 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
? '已自动关联工作目录中的虚拟环境'
|
||||
: creatable
|
||||
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
|
||||
: '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv',
|
||||
: '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv',
|
||||
uv_available: creatable,
|
||||
has_pyproject: creatable,
|
||||
has_requirements: false,
|
||||
@@ -189,21 +316,221 @@ describe('TaskDashboard', () => {
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to python mirrors from the sidebar', async () => {
|
||||
it('navigates to the python settings page from the sidebar', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Python 镜像' }),
|
||||
await screen.findByRole('button', { name: 'Python 环境' }),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Python 镜像' }),
|
||||
await screen.findByRole('heading', { name: 'Python 环境' }),
|
||||
).toBeInTheDocument()
|
||||
// 镜像和 uv 在同一页
|
||||
expect(
|
||||
await screen.findByRole('row', { name: /清华 TUNA/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText('uv 可执行文件'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves the uv executable and env vars picked on the python page', 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(
|
||||
screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }),
|
||||
)
|
||||
await user.type(
|
||||
screen.getByLabelText('uv 可执行文件'),
|
||||
'D:\\tools\\uv.exe',
|
||||
)
|
||||
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 waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/uv') && init?.method === 'PUT',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const saved = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/uv') && init?.method === 'PUT',
|
||||
)
|
||||
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
|
||||
source: 'custom',
|
||||
custom_path: 'D:\\tools\\uv.exe',
|
||||
extra_env: { UV_CACHE_DIR: 'D:\\uv-cache' },
|
||||
})
|
||||
})
|
||||
|
||||
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 waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/uv/download') &&
|
||||
init?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
expect((await screen.findAllByText('uv 0.9.0')).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('downloads a python version with uv', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
|
||||
await user.type(screen.getByLabelText('要下载的 Python 版本'), '3.12')
|
||||
await user.click(screen.getByRole('button', { name: '下载 Python' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/pythons') &&
|
||||
init?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const installed = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/pythons') &&
|
||||
init?.method === 'POST',
|
||||
)
|
||||
expect(JSON.parse(String(installed?.[1]?.body))).toEqual({
|
||||
version: '3.12',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a managed python environment with prefetched packages', async () => {
|
||||
const fetchMock = createFetchMock({
|
||||
pythons: [
|
||||
{
|
||||
version: '3.11.11',
|
||||
path: 'D:\\TaskPilot\\python\\cpython-3.11.11-windows-x86_64-none\\python.exe',
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
|
||||
expect(await screen.findByText('还没有预创建的环境')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建环境' }))
|
||||
await user.type(screen.getByLabelText('环境名'), 'data-science')
|
||||
await user.click(screen.getByRole('button', { name: '默认(uv 自动选)' }))
|
||||
await user.click(await screen.findByRole('option', { name: '3.11.11' }))
|
||||
await user.type(
|
||||
screen.getByLabelText(/预装包/),
|
||||
'numpy==2.1.0{enter}pandas',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/envs') &&
|
||||
init?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const created = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/envs') &&
|
||||
init?.method === 'POST',
|
||||
)
|
||||
expect(JSON.parse(String(created?.[1]?.body))).toEqual({
|
||||
name: 'data-science',
|
||||
python_version: '3.11.11',
|
||||
packages: ['numpy==2.1.0', 'pandas'],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows installed packages of a managed environment', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
envs: [
|
||||
{
|
||||
name: 'data-science',
|
||||
path: 'D:\\TaskPilot\\data\\envs\\data-science',
|
||||
python_version: '3.11.11',
|
||||
},
|
||||
],
|
||||
envPackages: {
|
||||
'data-science': [{ name: 'numpy', version: '2.1.0' }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
|
||||
await user.click(await screen.findByRole('button', { name: '查看包' }))
|
||||
|
||||
expect(await screen.findByText('numpy')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.1.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates the python environment before saving a new task', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.clear(screen.getByLabelText(/任务标识/))
|
||||
await user.type(screen.getByLabelText(/任务标识/), 'uv_env_task')
|
||||
await user.type(screen.getByLabelText('名称'), 'uv env task')
|
||||
await user.type(
|
||||
screen.getByLabelText(/项目工作目录/),
|
||||
'D:\\Projects\\uvproject',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '保存并热刷新' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const requests = fetchMock.mock.calls.map(([input, init]) => ({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
}))
|
||||
const venvIndex = requests.findIndex((request) =>
|
||||
request.url.endsWith('/api/v1/python/venv/create'),
|
||||
)
|
||||
const taskIndex = requests.findIndex(
|
||||
(request) =>
|
||||
request.url.endsWith('/api/v1/tasks') && request.method === 'POST',
|
||||
)
|
||||
expect(venvIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(taskIndex).toBeGreaterThan(venvIndex)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||
@@ -284,7 +611,7 @@ describe('TaskDashboard', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, {
|
||||
await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Layout, Result } from '@stash/ui'
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||
import { AppSidebar } from './components/AppSidebar'
|
||||
import { PythonMirrorsPage } from './pages/PythonMirrorsPage'
|
||||
import { AppHeader } from './components/AppHeader'
|
||||
import { TaskDashboard } from './pages/TaskDashboard'
|
||||
import './App.css'
|
||||
|
||||
@@ -24,15 +23,13 @@ function App() {
|
||||
return (
|
||||
<Layout
|
||||
className="app-root"
|
||||
sider={<AppSidebar />}
|
||||
siderWidth={224}
|
||||
header={<AppHeader />}
|
||||
maxWidth="full"
|
||||
contentPadding="none"
|
||||
>
|
||||
<div className="page-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="/mirrors" element={<PythonMirrorsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,17 @@ export type CreatePythonMirrorRequest =
|
||||
components['schemas']['CreatePythonMirrorRequest']
|
||||
export type UpdatePythonMirrorRequest =
|
||||
components['schemas']['UpdatePythonMirrorRequest']
|
||||
export type UvSettings = components['schemas']['UvSettingsResponse']
|
||||
export type UvCandidate = components['schemas']['UvCandidate']
|
||||
export type UvSource = components['schemas']['UvSource']
|
||||
export type UpdateUvSettingsRequest =
|
||||
components['schemas']['UpdateUvSettingsRequest']
|
||||
export type ManagedEnv = components['schemas']['ManagedEnvSummary']
|
||||
export type ManagedEnvPackage = components['schemas']['ManagedEnvPackage']
|
||||
export type CreateManagedEnvRequest =
|
||||
components['schemas']['CreateManagedEnvRequest']
|
||||
export type ManagedPython = components['schemas']['ManagedPython']
|
||||
export type InstallPythonRequest = components['schemas']['InstallPythonRequest']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -359,3 +370,69 @@ export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
/** uv 设置:只用程序目录 uv 或自己指定的可执行文件(不读系统 PATH)。 */
|
||||
export function getUvSettings(signal?: AbortSignal): Promise<UvSettings> {
|
||||
return requestJson<UvSettings>('/api/v1/uv', { signal })
|
||||
}
|
||||
|
||||
export function updateUvSettings(
|
||||
request: UpdateUvSettingsRequest,
|
||||
): Promise<UvSettings> {
|
||||
return requestJson<UvSettings>('/api/v1/uv', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
/** 本机没有 uv 时,从镜像下载到程序目录并切换过去。 */
|
||||
export function downloadUv(): Promise<UvSettings> {
|
||||
return requestJson<UvSettings>('/api/v1/uv/download', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** 预创建的托管虚拟环境(放在 data/envs 下)。 */
|
||||
export function listManagedEnvs(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedEnv[]> {
|
||||
return requestJson<ManagedEnv[]>('/api/v1/python/envs', { signal })
|
||||
}
|
||||
|
||||
export function createManagedEnv(
|
||||
request: CreateManagedEnvRequest,
|
||||
): Promise<ManagedEnv> {
|
||||
return requestJson<ManagedEnv>('/api/v1/python/envs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function listManagedEnvPackages(
|
||||
name: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedEnvPackage[]> {
|
||||
return requestJson<ManagedEnvPackage[]>(
|
||||
`/api/v1/python/envs/${encodeURIComponent(name)}/packages`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteManagedEnv(name: string): Promise<void> {
|
||||
return requestJson<void>(
|
||||
`/api/v1/python/envs/${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** uv 下载到平台目录(`python\`)里的解释器。 */
|
||||
export function listPythons(signal?: AbortSignal): Promise<ManagedPython[]> {
|
||||
return requestJson<ManagedPython[]>('/api/v1/python/pythons', { signal })
|
||||
}
|
||||
|
||||
export function installPython(
|
||||
request: InstallPythonRequest,
|
||||
): Promise<ManagedPython[]> {
|
||||
return requestJson<ManagedPython[]>('/api/v1/python/pythons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/envs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_envs"];
|
||||
put?: never;
|
||||
post: operations["create_env"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/envs/{name}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["delete_env"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/envs/{name}/packages": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_env_packages"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -100,6 +148,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/pythons": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_pythons"];
|
||||
put?: never;
|
||||
post: operations["install_python"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/venv": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -356,12 +420,52 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uv": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["get_uv_settings"];
|
||||
put: operations["update_uv_settings"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uv/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["download_uv"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** @enum {string} */
|
||||
ConcurrencyPolicy: "skip";
|
||||
CreateManagedEnvRequest: {
|
||||
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
|
||||
name: string;
|
||||
/** @description 预装包,每行一个,例如 `numpy==2.1.0` */
|
||||
packages?: string[];
|
||||
/** @description 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定 */
|
||||
python_version?: string | null;
|
||||
};
|
||||
CreatePythonMirrorRequest: {
|
||||
enabled?: boolean;
|
||||
index_url: string;
|
||||
@@ -426,6 +530,10 @@ export interface components {
|
||||
};
|
||||
/** @enum {string} */
|
||||
HttpMethod: "GET" | "POST";
|
||||
InstallPythonRequest: {
|
||||
/** @description 要下载的版本,例如 `3.12` 或 `3.12.4` */
|
||||
version: string;
|
||||
};
|
||||
/** @enum {string} */
|
||||
InterpreterKind: "python" | "node" | "other";
|
||||
LogChunkResponse: {
|
||||
@@ -434,6 +542,22 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
next_offset: number;
|
||||
};
|
||||
ManagedEnvPackage: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
ManagedEnvSummary: {
|
||||
name: string;
|
||||
/** @description 虚拟环境根目录的绝对路径 */
|
||||
path: string;
|
||||
/** @description 取自 `pyvenv.cfg` 的 `version_info` */
|
||||
python_version?: string | null;
|
||||
};
|
||||
ManagedPython: {
|
||||
/** @description uv 下载到平台目录下的解释器路径 */
|
||||
path: string;
|
||||
version: string;
|
||||
};
|
||||
/** @description 一个 Python 包索引镜像(pip / uv)。 */
|
||||
PythonMirrorResponse: {
|
||||
/** Format: date-time */
|
||||
@@ -611,6 +735,49 @@ export interface components {
|
||||
timeout_secs?: number;
|
||||
timezone?: string;
|
||||
};
|
||||
UpdateUvSettingsRequest: {
|
||||
custom_path?: string | null;
|
||||
/** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */
|
||||
extra_env?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
source: components["schemas"]["UvSource"];
|
||||
};
|
||||
UvCandidate: {
|
||||
available: boolean;
|
||||
path: string;
|
||||
source: components["schemas"]["UvSource"];
|
||||
version?: string | null;
|
||||
};
|
||||
UvSettingsResponse: {
|
||||
/** @description 实际生效的 uv 缓存目录(绝对路径) */
|
||||
cache_dir: string;
|
||||
/** @description 各来源的探测结果 */
|
||||
candidates: components["schemas"]["UvCandidate"][];
|
||||
custom_path?: string | null;
|
||||
/** @description 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行 */
|
||||
env_defaults: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** @description 当前设置指向的 uv;不可用时 `version` 为空 */
|
||||
executable?: string | null;
|
||||
/** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */
|
||||
extra_env: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** @description 程序目录下的 uv 目录 */
|
||||
managed_dir: string;
|
||||
/** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */
|
||||
python_dir: string;
|
||||
source: components["schemas"]["UvSource"];
|
||||
version?: string | null;
|
||||
};
|
||||
/**
|
||||
* @description uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
||||
* 会随启动方式变化,不适合当调度器的隐式依赖。
|
||||
* @enum {string}
|
||||
*/
|
||||
UvSource: "managed" | "custom";
|
||||
/** @description 编辑器在用户填写工作目录时发起的探测请求。 */
|
||||
VenvProbeRequest: {
|
||||
/** @description 解释器或可执行程序,对应 `action.interpreter` */
|
||||
@@ -621,7 +788,7 @@ export interface components {
|
||||
working_dir: string;
|
||||
};
|
||||
VenvProbeResponse: {
|
||||
/** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */
|
||||
/** @description 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境) */
|
||||
can_create: boolean;
|
||||
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
||||
display_path?: string | null;
|
||||
@@ -729,6 +896,121 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_envs: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Managed virtual environments */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ManagedEnvSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_env: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateManagedEnvRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Virtual environment created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ManagedEnvSummary"];
|
||||
};
|
||||
};
|
||||
/** @description Validation or uv failure */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_env: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Environment name */
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Environment deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Environment not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_env_packages: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Environment name */
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Installed packages */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ManagedEnvPackage"][];
|
||||
};
|
||||
};
|
||||
/** @description Environment not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_python_mirrors: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -939,6 +1221,68 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_pythons: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Pythons downloaded by uv into the platform directory */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ManagedPython"][];
|
||||
};
|
||||
};
|
||||
/** @description uv unavailable */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
install_python: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["InstallPythonRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Python downloaded; returns the installed list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ManagedPython"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation or uv failure */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
probe_python_venv: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1544,4 +1888,86 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_uv_settings: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description uv settings and detected executables */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UvSettingsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_uv_settings: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateUvSettingsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description uv settings saved */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UvSettingsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
download_uv: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description uv downloaded from the mirror and switched to it */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UvSettingsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Download, verification or install failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Brand, Space, Tag, Text } from '@stash/ui'
|
||||
import { fetchHealth } from '../api/client'
|
||||
|
||||
/**
|
||||
* 顶部导航:品牌 + 引擎状态。
|
||||
*
|
||||
* 配置入口都收进任务调度页的三栏卡片里(uv / 镜像源 / 虚拟环境都用弹窗),
|
||||
* 所以这里不再放页面级导航。
|
||||
*/
|
||||
export function AppHeader() {
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: ({ signal }) => fetchHealth(signal),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const status = healthQuery.isPending
|
||||
? { tone: 'neutral' as const, label: '检测中' }
|
||||
: healthQuery.isError
|
||||
? { tone: 'danger' as const, label: '引擎离线' }
|
||||
: { tone: 'success' as const, label: '引擎在线' }
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Brand, Icon, Menu, Tag, Text, type MenuItem } from '@stash/ui'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchHealth } from '../api/client'
|
||||
|
||||
const NAV_ITEMS: MenuItem[] = [
|
||||
{
|
||||
key: '/',
|
||||
label: '任务调度',
|
||||
icon: <Icon name="calendar_2_line" />,
|
||||
},
|
||||
{
|
||||
key: '/mirrors',
|
||||
label: 'Python 镜像',
|
||||
icon: <Icon name="download_line" />,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 左侧导航:品牌 / 主导航 / 引擎状态。
|
||||
*
|
||||
* 页面本身只负责内容,导航与全局状态收在这里;窄屏时 Layout 会把侧栏
|
||||
* 堆到内容上方,这里再用横向布局变形成顶部导航条。
|
||||
*/
|
||||
export function AppSidebar() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const activeKey = location.pathname === '/mirrors' ? '/mirrors' : '/'
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: ({ signal }) => fetchHealth(signal),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const status = healthQuery.isPending
|
||||
? { tone: 'neutral' as const, label: '检测中' }
|
||||
: healthQuery.isError
|
||||
? { tone: 'danger' as const, label: '引擎离线' }
|
||||
: { tone: 'success' as const, label: '引擎在线' }
|
||||
|
||||
return (
|
||||
<div className="app-sider">
|
||||
<div className="app-sider-brand">
|
||||
<Brand name="TaskPilot" mark="TP" />
|
||||
<Text className="app-sider-tagline" size="xs" tone="muted">
|
||||
本地任务调度 · Windows
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
className="app-nav"
|
||||
items={NAV_ITEMS}
|
||||
activeKey={activeKey}
|
||||
onSelect={(key) => navigate(key)}
|
||||
itemHeight={38}
|
||||
/>
|
||||
|
||||
<div className="app-sider-foot">
|
||||
<div className="app-sider-status">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Space, Text, Title } from '@stash/ui'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** 页面标题区:左侧标题 / 说明,右侧操作。所有页面共用。 */
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div className="header-title">
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<Title level={1}>{title}</Title>
|
||||
{subtitle !== undefined ? <Text tone="sub">{subtitle}</Text> : null}
|
||||
</div>
|
||||
{actions !== undefined ? (
|
||||
<Space gap="sm" wrap>
|
||||
{actions}
|
||||
</Space>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
Result,
|
||||
Select,
|
||||
Space,
|
||||
Spinner,
|
||||
Table,
|
||||
Tag,
|
||||
Text,
|
||||
Textarea,
|
||||
TextButton,
|
||||
toast,
|
||||
type TableColumn,
|
||||
} from '@stash/ui'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createManagedEnv,
|
||||
deleteManagedEnv,
|
||||
listManagedEnvPackages,
|
||||
listManagedEnvs,
|
||||
listPythons,
|
||||
type ManagedEnv,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
|
||||
const ENV_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称', width: 200 },
|
||||
{ key: 'python_version', title: 'Python', width: 120 },
|
||||
{ key: 'path', title: '路径', width: 380 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 180 },
|
||||
]
|
||||
|
||||
const PACKAGE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '包', width: 320 },
|
||||
{ key: 'version', title: '版本', width: 160 },
|
||||
]
|
||||
|
||||
function linesToPackages(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「Python 环境」页的托管虚拟环境区块。
|
||||
*
|
||||
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
|
||||
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
|
||||
*/
|
||||
export function PythonEnvsSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [pythonVersion, setPythonVersion] = useState('')
|
||||
const [packagesText, setPackagesText] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedEnv | null>(null)
|
||||
|
||||
const envsQuery = useQuery({
|
||||
queryKey: ['managed-envs'],
|
||||
queryFn: ({ signal }) => listManagedEnvs(signal),
|
||||
})
|
||||
const pythonsQuery = useQuery({
|
||||
queryKey: ['managed-pythons'],
|
||||
queryFn: ({ signal }) => listPythons(signal),
|
||||
})
|
||||
const pythonOptions = [
|
||||
{ value: '', label: '默认(uv 自动选)' },
|
||||
...(pythonsQuery.data ?? []).map((python) => ({
|
||||
value: python.version,
|
||||
label: python.version,
|
||||
})),
|
||||
]
|
||||
const packagesQuery = useQuery({
|
||||
queryKey: ['managed-env-packages', selected],
|
||||
queryFn: ({ signal }) => listManagedEnvPackages(selected ?? '', signal),
|
||||
enabled: Boolean(selected),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createManagedEnv({
|
||||
name: name.trim(),
|
||||
python_version: pythonVersion.trim() || null,
|
||||
packages: linesToPackages(packagesText),
|
||||
}),
|
||||
onSuccess: (env: ManagedEnv) => {
|
||||
setCreateOpen(false)
|
||||
setName('')
|
||||
setPythonVersion('')
|
||||
setPackagesText('')
|
||||
toast.success(
|
||||
env.python_version
|
||||
? `已创建环境 ${env.name}(Python ${env.python_version})`
|
||||
: `已创建环境 ${env.name}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['managed-envs'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (env: ManagedEnv) => deleteManagedEnv(env.name),
|
||||
onSuccess: (_data, env) => {
|
||||
setDeleteTarget(null)
|
||||
if (selected === env.name) {
|
||||
setSelected(null)
|
||||
}
|
||||
toast.success(`已删除环境 ${env.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['managed-envs'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const envs = envsQuery.data ?? []
|
||||
const rows = envs.map((env) => ({
|
||||
id: env.name,
|
||||
name: <Text strong>{env.name}</Text>,
|
||||
python_version: env.python_version ? (
|
||||
<Tag shape="rounded">{env.python_version}</Tag>
|
||||
) : (
|
||||
<Text size="sm" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
path: (
|
||||
<code className="code-chip" title={env.path}>
|
||||
{env.path}
|
||||
</code>
|
||||
),
|
||||
actions: (
|
||||
<Space justify="end" gap="xs" wrap>
|
||||
<TextButton onClick={() => setSelected(env.name)}>查看包</TextButton>
|
||||
<TextButton tone="danger" onClick={() => setDeleteTarget(env)}>
|
||||
删除
|
||||
</TextButton>
|
||||
</Space>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="md">
|
||||
<Space justify="between" align="center" wrap>
|
||||
<Space direction="column" gap="xs">
|
||||
<Text strong>虚拟环境</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
预创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
|
||||
</Text>
|
||||
</Space>
|
||||
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card padding="none">
|
||||
{envsQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="虚拟环境列表加载失败"
|
||||
description={envsQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void envsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : envsQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取虚拟环境" />
|
||||
<Text tone="sub">正在读取虚拟环境...</Text>
|
||||
</div>
|
||||
) : envs.length === 0 ? (
|
||||
<Empty
|
||||
className="empty-state"
|
||||
text="还没有预创建的环境"
|
||||
action={<Button onClick={() => setCreateOpen(true)}>新建环境</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Table columns={ENV_COLUMNS} rows={rows} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selected ? (
|
||||
<Card padding="none">
|
||||
<div className="env-packages-head">
|
||||
<Text strong>{selected} 已安装的包</Text>
|
||||
<Space gap="xs" align="center">
|
||||
<Text size="xs" tone="muted">
|
||||
共 {packagesQuery.data?.length ?? 0} 个
|
||||
</Text>
|
||||
<TextButton onClick={() => setSelected(null)}>收起</TextButton>
|
||||
</Space>
|
||||
</div>
|
||||
{packagesQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="包列表加载失败"
|
||||
description={packagesQuery.error.message}
|
||||
/>
|
||||
) : packagesQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取已安装的包" />
|
||||
</div>
|
||||
) : (packagesQuery.data?.length ?? 0) === 0 ? (
|
||||
<Empty className="empty-state" text="这个环境里没有安装第三方包" />
|
||||
) : (
|
||||
<Table
|
||||
columns={PACKAGE_COLUMNS}
|
||||
rows={packagesQuery.data.map((item) => ({
|
||||
id: item.name,
|
||||
name: <Text className="mono">{item.name}</Text>,
|
||||
version: <Text className="mono">{item.version}</Text>,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Notice title="环境创建后不会自动更新">
|
||||
「新建环境」会用 uv 建一个空环境并按需预装包(需要网络);之后不会自动升级依赖。
|
||||
运行任务不会改动这些环境。
|
||||
</Notice>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
onClose={() => {
|
||||
if (!createMutation.isPending) {
|
||||
setCreateOpen(false)
|
||||
}
|
||||
}}
|
||||
title="新建虚拟环境"
|
||||
size="md"
|
||||
closeOnOverlay={!createMutation.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-env-form"
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="create-env-form"
|
||||
className="mirror-form"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error('请填写环境名')
|
||||
return
|
||||
}
|
||||
createMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
label="环境名"
|
||||
htmlFor="env-name"
|
||||
hint="只能包含字母、数字、点、下划线、连字符;同时是 data\envs 下的目录名"
|
||||
>
|
||||
<Input
|
||||
id="env-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mono-input"
|
||||
placeholder="data-science"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Python 版本(可选)"
|
||||
hint={
|
||||
(pythonsQuery.data?.length ?? 0) === 0
|
||||
? '还没有下载 Python;先在上方「Python 版本」里下载,或选默认让 uv 自动下载'
|
||||
: '列表来自 uv 下载到平台目录的版本;留空由 uv 决定'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
options={pythonOptions}
|
||||
value={pythonVersion}
|
||||
onChange={setPythonVersion}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="预装包(可选)"
|
||||
htmlFor="env-packages"
|
||||
hint="每行一个,可带版本,例如 numpy==2.1.0"
|
||||
>
|
||||
<Textarea
|
||||
id="env-packages"
|
||||
value={packagesText}
|
||||
onChange={(event) => setPackagesText(event.target.value)}
|
||||
rows={5}
|
||||
placeholder={'numpy==2.1.0\npandas\nrequests'}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除环境 ${deleteTarget?.name ?? ''}`}
|
||||
description="会直接删除 data\envs 下的整个环境目录,里面安装的包一并删除;正在使用它的任务下次运行会失败。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+18
-16
@@ -24,12 +24,11 @@ import {
|
||||
updatePythonMirror,
|
||||
type PythonMirror,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
import {
|
||||
PythonMirrorModal,
|
||||
type PythonMirrorFormValues,
|
||||
} from '../components/PythonMirrorModal'
|
||||
} from './PythonMirrorModal'
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称', width: 180 },
|
||||
@@ -49,12 +48,12 @@ function toRequest(values: PythonMirrorFormValues) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 包镜像管理。
|
||||
* 「Python 环境」页的包索引镜像区块。
|
||||
*
|
||||
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
|
||||
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
|
||||
*/
|
||||
export function PythonMirrorsPage() {
|
||||
export function PythonMirrorsSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
|
||||
@@ -166,22 +165,25 @@ export function PythonMirrorsPage() {
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader
|
||||
eyebrow="PYTHON / PACKAGE MIRROR"
|
||||
title="Python 镜像"
|
||||
subtitle="管理 pip / uv 的包索引;默认镜像会在脚本任务运行时注入环境变量。"
|
||||
actions={<Button onClick={openCreate}>+ 新增镜像</Button>}
|
||||
/>
|
||||
<>
|
||||
<Card padding="md">
|
||||
<Space justify="between" align="center" wrap>
|
||||
<Space direction="column" gap="xs">
|
||||
<Text strong>包索引镜像</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
|
||||
</Text>
|
||||
</Space>
|
||||
<Button onClick={openCreate}>+ 新增镜像</Button>
|
||||
</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>
|
||||
<Title level={2}>{defaultMirror?.name ?? '未设置默认镜像'}</Title>
|
||||
<Text className="mono" size="sm" tone="sub">
|
||||
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
|
||||
</Text>
|
||||
@@ -270,6 +272,6 @@ export function PythonMirrorsPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,8 @@ export function TaskEditorModal({
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
// 新建 Python 任务时先跑一次 uv 建环境,这期间不允许关闭弹窗。
|
||||
const [preparingVenv, setPreparingVenv] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||
@@ -338,8 +340,10 @@ export function TaskEditorModal({
|
||||
document.getElementById(task ? 'task-name' : 'task-id')?.focus()
|
||||
}, [open, task])
|
||||
|
||||
const busy = saving || preparingVenv
|
||||
|
||||
const requestClose = () => {
|
||||
if (!saving) {
|
||||
if (!busy) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
@@ -347,6 +351,39 @@ export function TaskEditorModal({
|
||||
const submit = handleSubmit(async (values) => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
// 新建 Python 任务时先把环境准备好:环境问题当场暴露,不用等第一次运行。
|
||||
if (
|
||||
!task &&
|
||||
values.actionType === 'script' &&
|
||||
values.script.language === 'python'
|
||||
) {
|
||||
const workingDir = values.script.working_dir.trim()
|
||||
if (workingDir) {
|
||||
setPreparingVenv(true)
|
||||
try {
|
||||
const probe = await probePythonVenv({
|
||||
working_dir: workingDir,
|
||||
interpreter: values.script.interpreter.trim() || 'python.exe',
|
||||
venv: values.script.venv.trim() || null,
|
||||
})
|
||||
if (probe.can_create) {
|
||||
const created = await createPythonVenv({
|
||||
working_dir: workingDir,
|
||||
})
|
||||
toast.success(
|
||||
created.venv_path
|
||||
? `已创建虚拟环境:${created.venv_path}`
|
||||
: '虚拟环境已创建',
|
||||
)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['python-venv'],
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setPreparingVenv(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
await onSubmit(values)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError && error.field) {
|
||||
@@ -372,14 +409,18 @@ export function TaskEditorModal({
|
||||
title={task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
||||
size="lg"
|
||||
className="task-editor-modal"
|
||||
closeOnOverlay={!saving}
|
||||
closeOnOverlay={!busy}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
||||
<Button variant="secondary" onClick={requestClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="task-editor-form" loading={saving}>
|
||||
{task ? '保存修改' : '保存并热刷新'}
|
||||
<Button type="submit" form="task-editor-form" loading={busy}>
|
||||
{preparingVenv
|
||||
? '正在创建虚拟环境…'
|
||||
: task
|
||||
? '保存修改'
|
||||
: '保存并热刷新'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,17 @@ export function actionTypeLabel(type: TaskActionType): string {
|
||||
}[type]
|
||||
}
|
||||
|
||||
/** uv --version 是 `uv 0.13.7 (hash 日期 架构)`,界面只显示版本号。 */
|
||||
export function shortUvVersion(
|
||||
version: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!version) {
|
||||
return undefined
|
||||
}
|
||||
const number = version.replace(/^uv\s+/i, '').split('(')[0]?.trim()
|
||||
return number || version
|
||||
}
|
||||
|
||||
export const runStatusLabels: Record<RunStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SCRIPT_LANGUAGE_META: Record<
|
||||
defaultScript: 'main.py',
|
||||
scriptLabel: '脚本文件或相对路径',
|
||||
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
|
||||
hint: '自动使用项目 .venv;没有环境时可在下方用 uv 创建。',
|
||||
hint: '优先使用项目里的 .venv;没有环境时保存任务会自动用 uv 创建。',
|
||||
},
|
||||
shell: {
|
||||
label: 'Shell (cmd)',
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Grid,
|
||||
Modal,
|
||||
Notice,
|
||||
Pagination,
|
||||
Result,
|
||||
@@ -16,13 +17,11 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Spinner,
|
||||
Table,
|
||||
Tag,
|
||||
Text,
|
||||
TextButton,
|
||||
Title,
|
||||
toast,
|
||||
type TableColumn,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@@ -32,6 +31,9 @@ import {
|
||||
fetchHealth,
|
||||
fetchSchedule,
|
||||
fetchStats,
|
||||
getUvSettings,
|
||||
listManagedEnvs,
|
||||
listPythonMirrors,
|
||||
listTasks,
|
||||
runTask,
|
||||
setTaskEnabled,
|
||||
@@ -41,11 +43,18 @@ import {
|
||||
type TaskFilters,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
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 { TaskEditorModal } from '../components/TaskEditorModal'
|
||||
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
|
||||
import { UvSettingsSection } from '../components/UvSettingsSection'
|
||||
import {
|
||||
actionTypeLabel,
|
||||
formatShortDateTime,
|
||||
shortUvVersion,
|
||||
} from '../lib/format'
|
||||
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||
import {
|
||||
type TaskFormValues,
|
||||
@@ -68,13 +77,11 @@ const ACTION_TYPE_OPTIONS = [
|
||||
{ value: 'process', label: '进程' },
|
||||
]
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称 / 任务标识', width: 230 },
|
||||
{ key: 'timezone', title: '时区', width: 110 },
|
||||
{ key: 'schedule', title: '调度周期', width: 200 },
|
||||
{ key: 'status', title: '状态', width: 100 },
|
||||
{ key: 'timeout', title: '超时', width: 80 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 240 },
|
||||
/// 中间栏按任务类型分组,和顶部筛选配合使用。
|
||||
const TASK_GROUPS: Array<{ type: TaskActionType; label: string }> = [
|
||||
{ type: 'script', label: '脚本' },
|
||||
{ type: 'http', label: 'HTTP 请求' },
|
||||
{ type: 'process', label: '二进制程序' },
|
||||
]
|
||||
|
||||
export function TaskDashboard() {
|
||||
@@ -91,6 +98,9 @@ export function TaskDashboard() {
|
||||
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)
|
||||
const [queryDraft, setQueryDraft] = useState(q)
|
||||
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
||||
|
||||
@@ -132,9 +142,26 @@ export function TaskDashboard() {
|
||||
queryFn: ({ signal }) => fetchSchedule(signal),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const uvQuery = useQuery({
|
||||
queryKey: ['uv-settings'],
|
||||
queryFn: ({ signal }) => getUvSettings(signal),
|
||||
})
|
||||
const mirrorsQuery = useQuery({
|
||||
queryKey: ['python-mirrors'],
|
||||
queryFn: ({ signal }) => listPythonMirrors(signal),
|
||||
})
|
||||
const envsQuery = useQuery({
|
||||
queryKey: ['managed-envs'],
|
||||
queryFn: ({ signal }) => listManagedEnvs(signal),
|
||||
})
|
||||
|
||||
const nextRuns = new Map(
|
||||
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
|
||||
)
|
||||
const defaultMirror =
|
||||
mirrorsQuery.data?.find((mirror) => mirror.is_default) ?? null
|
||||
const uv = uvQuery.data
|
||||
const managedEnvs = envsQuery.data ?? []
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
@@ -208,6 +235,11 @@ export function TaskDashboard() {
|
||||
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]
|
||||
|
||||
function updateFilters(changes: Record<string, string | undefined>) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
@@ -246,230 +278,254 @@ export function TaskDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tasks.map((task) => {
|
||||
const isRunning =
|
||||
runMutation.isPending && runMutation.variables?.id === task.id
|
||||
const isToggling =
|
||||
toggleMutation.isPending && toggleMutation.variables?.id === task.id
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
name: (
|
||||
<div className="task-name-cell">
|
||||
<div className="task-name-row">
|
||||
<Text className="task-name" strong>
|
||||
{task.name}
|
||||
</Text>
|
||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||
</div>
|
||||
<Text className="task-id" size="xs" tone="muted">
|
||||
{task.id}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
schedule: (
|
||||
<div className="schedule-cell">
|
||||
<code className="code-chip">{task.cron}</code>
|
||||
<Text size="xs" tone="muted">
|
||||
{task.enabled
|
||||
? nextRuns.get(task.id)
|
||||
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
|
||||
: '调度中…'
|
||||
: '未调度'}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
timezone: (
|
||||
<Text className="timezone-cell" size="sm" tone="sub">
|
||||
{task.timezone}
|
||||
</Text>
|
||||
),
|
||||
status: <EnabledBadge enabled={task.enabled} />,
|
||||
timeout: (
|
||||
<Text size="sm" tone="sub">
|
||||
{task.timeout_secs}s
|
||||
</Text>
|
||||
),
|
||||
actions: (
|
||||
<Space justify="end" gap="xs" wrap>
|
||||
<TextButton
|
||||
disabled={isRunning}
|
||||
title="立即运行"
|
||||
onClick={() => runMutation.mutate(task)}
|
||||
>
|
||||
{isRunning ? '提交中' : '运行'}
|
||||
</TextButton>
|
||||
<TextButton
|
||||
onClick={() => {
|
||||
setLogTask(task)
|
||||
setInitialRunId(null)
|
||||
}}
|
||||
>
|
||||
日志
|
||||
</TextButton>
|
||||
<TextButton onClick={() => openEdit(task)}>编辑</TextButton>
|
||||
<TextButton
|
||||
disabled={isToggling}
|
||||
onClick={() => toggleMutation.mutate(task)}
|
||||
>
|
||||
{task.enabled ? '停用' : '启用'}
|
||||
</TextButton>
|
||||
<TextButton tone="danger" onClick={() => setDeleteTarget(task)}>
|
||||
删除
|
||||
</TextButton>
|
||||
</Space>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader
|
||||
eyebrow="TASKPILOT / WINDOWS SCHEDULER"
|
||||
title="任务调度"
|
||||
subtitle="Rust 本地轻量化任务引擎 · Windows 原生调度中心"
|
||||
actions={<Button onClick={openCreate}>+ 新建任务</Button>}
|
||||
/>
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-head">
|
||||
<Space direction="column" gap="xs">
|
||||
<Title level={2} as="h1">
|
||||
任务调度
|
||||
</Title>
|
||||
<Text size="sm" tone="sub">
|
||||
Rust 本地轻量化任务引擎 · Windows 原生调度中心
|
||||
</Text>
|
||||
</Space>
|
||||
<Button onClick={openCreate}>+ 新建任务</Button>
|
||||
</div>
|
||||
|
||||
{actionError ? (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="操作失败"
|
||||
className="page-alert"
|
||||
action={{ label: '关闭', onClick: () => setActionError(null) }}
|
||||
>
|
||||
{actionError}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<section aria-label="任务统计" className="metrics-section">
|
||||
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
|
||||
<MetricCard
|
||||
label="已托管任务"
|
||||
value={statsQuery.data?.total}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="运行中任务"
|
||||
value={statsQuery.data?.running}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="可用脚本"
|
||||
value={statsQuery.data?.script}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Rust 引擎"
|
||||
value={
|
||||
healthQuery.isPending
|
||||
? '...'
|
||||
: healthQuery.isError
|
||||
? '离线'
|
||||
: '在线'
|
||||
}
|
||||
loading={false}
|
||||
danger={healthQuery.isError}
|
||||
/>
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
<Card padding="none" className="task-card">
|
||||
<div className="toolbar">
|
||||
<SearchField
|
||||
className="toolbar-search"
|
||||
value={queryDraft}
|
||||
onChange={setQueryDraft}
|
||||
placeholder="搜索任务名称或 ID"
|
||||
size="md"
|
||||
maxWidth={420}
|
||||
/>
|
||||
|
||||
<div className="toolbar-filters">
|
||||
<Select
|
||||
className="filter-select"
|
||||
options={ENABLED_OPTIONS}
|
||||
value={enabledValue ?? ''}
|
||||
onChange={(value) =>
|
||||
updateFilters({
|
||||
enabled: value || undefined,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
className="filter-select"
|
||||
options={ACTION_TYPE_OPTIONS}
|
||||
value={actionTypeValue ?? ''}
|
||||
onChange={(value) =>
|
||||
updateFilters({
|
||||
action_type: value || undefined,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<TextButton onClick={() => setSearchParams({})}>
|
||||
清除筛选
|
||||
</TextButton>
|
||||
) : null}
|
||||
<TextButton
|
||||
onClick={() => void refreshAll()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
刷新
|
||||
</TextButton>
|
||||
{/* 左栏:用户 / 概览 / 调度 */}
|
||||
<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" />
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="user-bio">
|
||||
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<Text strong size="sm">
|
||||
概览
|
||||
</Text>
|
||||
<div className="dash-metrics">
|
||||
<DashMetric
|
||||
label="已托管任务"
|
||||
value={statsQuery.data?.total}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<DashMetric
|
||||
label="运行中任务"
|
||||
value={statsQuery.data?.running}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<DashMetric
|
||||
label="可用脚本"
|
||||
value={statsQuery.data?.script}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<DashMetric
|
||||
label="Rust 引擎"
|
||||
value={
|
||||
healthQuery.isPending
|
||||
? '...'
|
||||
: healthQuery.isError
|
||||
? '离线'
|
||||
: '在线'
|
||||
}
|
||||
loading={false}
|
||||
danger={healthQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<Text strong size="sm">
|
||||
调度
|
||||
</Text>
|
||||
<div className="dash-kv">
|
||||
<Text size="xs" 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>
|
||||
|
||||
{/* 中栏:筛选 + 按类型分组的任务 */}
|
||||
<section className="dashboard-column dashboard-main" aria-label="任务列表">
|
||||
<Card padding="md">
|
||||
<Space gap="sm" wrap align="center">
|
||||
<SearchField
|
||||
className="toolbar-search"
|
||||
value={queryDraft}
|
||||
onChange={setQueryDraft}
|
||||
placeholder="搜索任务名称或 ID"
|
||||
size="md"
|
||||
maxWidth={360}
|
||||
/>
|
||||
<Space gap="xs" wrap align="center">
|
||||
<Select
|
||||
className="filter-select"
|
||||
options={ENABLED_OPTIONS}
|
||||
value={enabledValue ?? ''}
|
||||
onChange={(value) =>
|
||||
updateFilters({
|
||||
enabled: value || undefined,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
className="filter-select"
|
||||
options={ACTION_TYPE_OPTIONS}
|
||||
value={actionTypeValue ?? ''}
|
||||
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>
|
||||
|
||||
{actionError ? (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="操作失败"
|
||||
className="page-alert"
|
||||
action={{ label: '关闭', onClick: () => setActionError(null) }}
|
||||
>
|
||||
{actionError}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{tasksQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="任务列表加载失败"
|
||||
description={tasksQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void tasksQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card padding="none">
|
||||
<Result
|
||||
status="error"
|
||||
title="任务列表加载失败"
|
||||
description={tasksQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void tasksQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : tasksQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取任务" />
|
||||
<Text tone="sub">正在读取任务...</Text>
|
||||
</div>
|
||||
<Card padding="none">
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取任务" />
|
||||
<Text tone="sub">正在读取任务...</Text>
|
||||
</div>
|
||||
</Card>
|
||||
) : tasks.length === 0 ? (
|
||||
<Empty
|
||||
className="empty-state"
|
||||
text={
|
||||
<span className="empty-copy">
|
||||
<Text as="span" size="lg" strong>
|
||||
{hasFilters ? '没有匹配任务' : '还没有任务'}
|
||||
</Text>
|
||||
<Text as="span" size="md" tone="sub">
|
||||
{hasFilters
|
||||
? '请调整搜索条件后重试。'
|
||||
: '创建第一个定时任务,开始使用真实调度能力。'}
|
||||
</Text>
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
hasFilters ? undefined : (
|
||||
<Button onClick={openCreate}>新建任务</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Card padding="none">
|
||||
<Empty
|
||||
className="empty-state"
|
||||
text={
|
||||
<span className="empty-copy">
|
||||
<Text as="span" size="lg" strong>
|
||||
{hasFilters ? '没有匹配任务' : '还没有任务'}
|
||||
</Text>
|
||||
<Text as="span" size="md" tone="sub">
|
||||
{hasFilters
|
||||
? '请调整搜索条件后重试。'
|
||||
: '创建第一个定时任务,开始使用真实调度能力。'}
|
||||
</Text>
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
hasFilters ? undefined : (
|
||||
<Button onClick={openCreate}>新建任务</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
className="task-table"
|
||||
columns={TABLE_COLUMNS}
|
||||
rows={rows}
|
||||
size="md"
|
||||
/>
|
||||
{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} 页
|
||||
@@ -483,7 +539,146 @@ export function TaskDashboard() {
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* 右栏:uv / 镜像 / 虚拟环境摘要(配置走弹窗) */}
|
||||
<aside className="dashboard-column dashboard-right" aria-label="Python 环境">
|
||||
<Card padding="md">
|
||||
<Space justify="between" align="center">
|
||||
<Text strong size="sm">
|
||||
uv
|
||||
</Text>
|
||||
<TextButton 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>
|
||||
<div className="dash-kv">
|
||||
<Text size="xs" tone="muted">
|
||||
缓存目录
|
||||
</Text>
|
||||
<Text className="mono" size="xs" tone="sub" title="UV_CACHE_DIR">
|
||||
{uv?.cache_dir ?? 'uv-cache'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="dash-kv">
|
||||
<Text size="xs" tone="muted">
|
||||
Python 目录
|
||||
</Text>
|
||||
<Text className="mono" size="xs" tone="sub">
|
||||
{uv?.python_dir ?? 'python'}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<Space justify="between" align="center">
|
||||
<Text strong size="sm">
|
||||
镜像源
|
||||
</Text>
|
||||
<TextButton onClick={() => setMirrorSettingsOpen(true)}>
|
||||
配置
|
||||
</TextButton>
|
||||
</Space>
|
||||
<div className="dash-kv">
|
||||
<Text size="xs" tone="muted">
|
||||
当前默认镜像
|
||||
</Text>
|
||||
<Text className="mono" size="xs" tone="sub">
|
||||
{defaultMirror?.index_url ?? '未设置'}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<Space justify="between" align="center">
|
||||
<Text strong size="sm">
|
||||
虚拟环境
|
||||
</Text>
|
||||
<TextButton onClick={() => setEnvManagerOpen(true)}>
|
||||
管理
|
||||
</TextButton>
|
||||
</Space>
|
||||
{managedEnvs.length === 0 ? (
|
||||
<Text size="sm" tone="muted">
|
||||
还没有预创建的环境
|
||||
</Text>
|
||||
) : (
|
||||
<div className="dash-list">
|
||||
{managedEnvs.slice(0, 5).map((env) => (
|
||||
<div className="dash-list-row" key={env.name}>
|
||||
<Text size="sm" strong>
|
||||
{env.name}
|
||||
</Text>
|
||||
<Text size="xs" tone="muted" className="mono">
|
||||
{env.python_version ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
{/* 配置弹窗:uv 与镜像源分开,各自一个弹窗;关闭时不渲染内容,避免重复表单 */}
|
||||
{uvSettingsOpen ? (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => setUvSettingsOpen(false)}
|
||||
title="uv 配置"
|
||||
size="lg"
|
||||
>
|
||||
<UvSettingsSection onClose={() => setUvSettingsOpen(false)} />
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{mirrorSettingsOpen ? (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => setMirrorSettingsOpen(false)}
|
||||
title="镜像源配置"
|
||||
size="lg"
|
||||
footer={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setMirrorSettingsOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PythonMirrorsSection />
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{envManagerOpen ? (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => setEnvManagerOpen(false)}
|
||||
title="虚拟环境管理"
|
||||
size="lg"
|
||||
footer={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEnvManagerOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<PythonRuntimeSection />
|
||||
<PythonEnvsSection />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
<TaskEditorModal
|
||||
open={editorOpen}
|
||||
@@ -513,7 +708,7 @@ export function TaskDashboard() {
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除任务 ${deleteTarget?.id ?? ''}`}
|
||||
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
|
||||
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不可撤销。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
@@ -530,28 +725,90 @@ export function TaskDashboard() {
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
interface DashMetricProps {
|
||||
label: string
|
||||
value: number | string | undefined
|
||||
loading: boolean
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, loading, danger = false }: MetricCardProps) {
|
||||
function DashMetric({ label, value, loading, danger = false }: DashMetricProps) {
|
||||
return (
|
||||
<Card padding="md" className="metric-card" hoverable>
|
||||
<Text tone="sub" size="sm">
|
||||
<div className="dash-metric">
|
||||
<Text size="xs" tone="muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Title
|
||||
level={1}
|
||||
as="div"
|
||||
weight="medium"
|
||||
className={danger ? 'metric-value metric-danger' : 'metric-value'}
|
||||
>
|
||||
<span className={danger ? 'dash-metric-value dash-metric-danger' : 'dash-metric-value'}>
|
||||
{loading ? '...' : (value ?? '--')}
|
||||
</Title>
|
||||
</Card>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task
|
||||
nextRunAt: string | undefined
|
||||
running: boolean
|
||||
toggling: boolean
|
||||
onRun: () => void
|
||||
onLogs: () => void
|
||||
onEdit: () => void
|
||||
onToggle: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function TaskItem({
|
||||
task,
|
||||
nextRunAt,
|
||||
running,
|
||||
toggling,
|
||||
onRun,
|
||||
onLogs,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: 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">
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user