feat: 本地 uv 与托管 Python 环境管理

- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验

- uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程

- 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包

- 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用

- 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗

- 发布包构建后去掉工作区继承的低完整性标签

- 修复调度集成测试并发时误取 skipped 运行的问题
This commit is contained in:
2026-11-07 16:57:27 +08:00
parent 2aa2a1ac18
commit 1914d38451
36 changed files with 5168 additions and 508 deletions
+318 -2
View File
@@ -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
View File
@@ -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()
+2 -5
View File
@@ -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>
+77
View File
@@ -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),
})
}
+427 -1
View File
@@ -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"];
};
};
};
};
}
+45
View File
@@ -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>
)
}
-72
View File
@@ -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>
)
}
-32
View File
@@ -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)
}
}}
/>
</>
)
}
@@ -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>
)
}
+46 -5
View File
@@ -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>
)
}
+11
View File
@@ -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: '运行中',
+1 -1
View File
@@ -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)',
+498 -241
View File
@@ -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>
)
}