feat: 左侧导航改版与 Python 镜像管理
- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航 - 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方 - Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先 - 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除 - 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
This commit is contained in:
@@ -246,7 +246,9 @@ test('renders without external network access', async ({ page }) => {
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '任务调度' }),
|
||||
).toBeVisible()
|
||||
|
||||
const state = await page.evaluate(async () => {
|
||||
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
|
||||
@@ -331,3 +333,47 @@ test('keeps a long run log scrollable without rendering every line', async ({
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('manages python package mirrors', async ({ page, request }) => {
|
||||
const headers = await authHeaders(request)
|
||||
|
||||
// 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。
|
||||
const existing = await request.get('/api/v1/python/mirrors')
|
||||
expect(existing.ok()).toBeTruthy()
|
||||
const existingMirrors = (await existing.json()) as Array<{
|
||||
id: string
|
||||
index_url: string
|
||||
}>
|
||||
for (const mirror of existingMirrors) {
|
||||
if (mirror.index_url.includes('pypi.e2e.example')) {
|
||||
await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers })
|
||||
}
|
||||
}
|
||||
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
|
||||
|
||||
await page.goto('/mirrors')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Python 镜像' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '+ 新增镜像' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' })
|
||||
await dialog.getByLabel('名称').fill('E2E 镜像')
|
||||
await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
|
||||
const row = page.getByRole('row', { name: /E2E 镜像/ })
|
||||
await expect(row).toBeVisible()
|
||||
await row.getByRole('button', { name: '设为默认' }).click()
|
||||
await expect(row.getByText('默认', { exact: true })).toBeVisible()
|
||||
|
||||
await row.getByRole('button', { name: '删除' }).click()
|
||||
await page
|
||||
.getByRole('dialog', { name: /删除镜像/ })
|
||||
.getByRole('button', { name: '确认删除' })
|
||||
.click()
|
||||
await expect(row).toHaveCount(0)
|
||||
|
||||
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
|
||||
})
|
||||
|
||||
+180
-7
@@ -4,13 +4,68 @@
|
||||
* 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。
|
||||
*/
|
||||
|
||||
.app-shell {
|
||||
/* ---------- 应用外壳:左侧导航 + 内容 ---------- */
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
width: min(1040px, calc(100% - 48px));
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px;
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.app-sider-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-sider-foot {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--su-border);
|
||||
padding: 16px 8px 0;
|
||||
}
|
||||
|
||||
.app-sider-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: 48px 0;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
|
||||
.page-stack > *,
|
||||
.task-card,
|
||||
.task-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---------- 页头 ---------- */
|
||||
@@ -876,8 +931,119 @@
|
||||
background-color: var(--su-gray-6);
|
||||
}
|
||||
|
||||
/* ---------- Python 镜像 ---------- */
|
||||
|
||||
.mirror-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mirror-summary-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mirror-summary-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mirror-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mirror-table {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mirror-url {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mirror-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mirror-enabled-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
/* Layout 在窄屏把侧栏堆到上方;它的内部 main/inner 还保留内容最小宽度,
|
||||
表格这类宽内容会把整页撑开,这里强制回到容器宽度。 */
|
||||
.app-root > div,
|
||||
.app-root > div > div,
|
||||
.app-root main,
|
||||
.app-root main > div {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
position: static;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
overflow: visible;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.app-sider-brand {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-sider-tagline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Menu 内部是纵向 ul,窄屏改横向 */
|
||||
.app-nav > ul {
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-nav > ul > li {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.app-nav > ul button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.app-sider-foot {
|
||||
margin-top: 0;
|
||||
border-top: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.run-log-body {
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr);
|
||||
@@ -885,10 +1051,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
width: min(100% - 24px, 1040px);
|
||||
gap: 20px;
|
||||
padding: 24px 0 40px;
|
||||
.page-container {
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.app-sider-foot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
@@ -83,6 +83,20 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/mirrors')) {
|
||||
return jsonResponse([
|
||||
{
|
||||
id: 'tsinghua',
|
||||
name: '清华 TUNA',
|
||||
index_url: 'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
trusted_host: null,
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
created_at: '2026-10-18T10:00:00Z',
|
||||
updated_at: '2026-10-18T10:00:00Z',
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -167,6 +181,23 @@ describe('TaskDashboard', () => {
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to python mirrors from the sidebar', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Python 镜像' }),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Python 镜像' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByRole('row', { name: /清华 TUNA/ }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
|
||||
+20
-7
@@ -1,5 +1,7 @@
|
||||
import { Button, Result } from '@stash/ui'
|
||||
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 { TaskDashboard } from './pages/TaskDashboard'
|
||||
import './App.css'
|
||||
|
||||
@@ -7,23 +9,34 @@ function NotFoundPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="page-stack">
|
||||
<Result
|
||||
status="info"
|
||||
title="页面不存在"
|
||||
description="该地址尚未配置到 TaskPilot 路由。"
|
||||
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
<Layout
|
||||
className="app-root"
|
||||
sider={<AppSidebar />}
|
||||
siderWidth={224}
|
||||
maxWidth="full"
|
||||
contentPadding="none"
|
||||
>
|
||||
<div className="page-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="/mirrors" element={<PythonMirrorsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ export type DirectoryListingResponse =
|
||||
components['schemas']['DirectoryListingResponse']
|
||||
export type TaskStats = components['schemas']['TaskStatsResponse']
|
||||
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
|
||||
export type PythonMirror = components['schemas']['PythonMirrorResponse']
|
||||
export type CreatePythonMirrorRequest =
|
||||
components['schemas']['CreatePythonMirrorRequest']
|
||||
export type UpdatePythonMirrorRequest =
|
||||
components['schemas']['UpdatePythonMirrorRequest']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -303,3 +308,42 @@ export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
|
||||
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
|
||||
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
|
||||
}
|
||||
|
||||
/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */
|
||||
export function listPythonMirrors(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PythonMirror[]> {
|
||||
return requestJson<PythonMirror[]>('/api/v1/python/mirrors', { signal })
|
||||
}
|
||||
|
||||
export function createPythonMirror(
|
||||
request: CreatePythonMirrorRequest,
|
||||
): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>('/api/v1/python/mirrors', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePythonMirror(
|
||||
id: string,
|
||||
request: UpdatePythonMirrorRequest,
|
||||
): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>(`/api/v1/python/mirrors/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePythonMirror(id: string): Promise<void> {
|
||||
return requestJson<void>(`/api/v1/python/mirrors/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>(
|
||||
`/api/v1/python/mirrors/${id}/default`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_python_mirrors"];
|
||||
put?: never;
|
||||
post: operations["create_python_mirror"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["get_python_mirror"];
|
||||
put: operations["update_python_mirror"];
|
||||
post?: never;
|
||||
delete: operations["delete_python_mirror"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors/{id}/default": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["set_default_python_mirror"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/venv": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -298,6 +346,12 @@ export interface components {
|
||||
schemas: {
|
||||
/** @enum {string} */
|
||||
ConcurrencyPolicy: "skip";
|
||||
CreatePythonMirrorRequest: {
|
||||
enabled?: boolean;
|
||||
index_url: string;
|
||||
name: string;
|
||||
trusted_host?: string | null;
|
||||
};
|
||||
CreateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
@@ -360,6 +414,22 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
next_offset: number;
|
||||
};
|
||||
/** @description 一个 Python 包索引镜像(pip / uv)。 */
|
||||
PythonMirrorResponse: {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
/** @description pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL` */
|
||||
index_url: string;
|
||||
/** @description 当前默认镜像;同一时间最多一个 */
|
||||
is_default: boolean;
|
||||
name: string;
|
||||
/** @description pip 的 `--trusted-host`;https 源通常为空 */
|
||||
trusted_host?: string | null;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
};
|
||||
RetryPolicy: {
|
||||
/** Format: int64 */
|
||||
delay_secs?: number;
|
||||
@@ -501,6 +571,12 @@ export interface components {
|
||||
*/
|
||||
total: number;
|
||||
};
|
||||
UpdatePythonMirrorRequest: {
|
||||
enabled: boolean;
|
||||
index_url: string;
|
||||
name: string;
|
||||
trusted_host?: string | null;
|
||||
};
|
||||
UpdateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
@@ -620,6 +696,216 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_python_mirrors: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Python package mirrors */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreatePythonMirrorRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Mirror created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror URL already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Mirror detail */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdatePythonMirrorRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Mirror updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror URL already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Mirror deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
set_default_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Default mirror updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
probe_python_venv: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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,214 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
Text,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import type { PythonMirror } from '../api/client'
|
||||
|
||||
const mirrorSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入镜像名称')
|
||||
.max(64, '名称不能超过 64 个字符'),
|
||||
index_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入索引地址')
|
||||
.refine((value) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
Boolean(url.hostname) &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, '请输入 http(s) 开头的有效索引地址'),
|
||||
trusted_host: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
!value || (!value.includes('://') && !value.includes('/') && !/\s/.test(value)),
|
||||
'只填主机名,可带端口,例如 mirrors.aliyun.com',
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export type PythonMirrorFormValues = z.infer<typeof mirrorSchema>
|
||||
|
||||
function emptyValues(): PythonMirrorFormValues {
|
||||
return { name: '', index_url: '', trusted_host: '', enabled: true }
|
||||
}
|
||||
|
||||
function toValues(mirror: PythonMirror): PythonMirrorFormValues {
|
||||
return {
|
||||
name: mirror.name,
|
||||
index_url: mirror.index_url,
|
||||
trusted_host: mirror.trusted_host ?? '',
|
||||
enabled: mirror.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
interface PythonMirrorModalProps {
|
||||
open: boolean
|
||||
mirror: PythonMirror | null
|
||||
saving: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: PythonMirrorFormValues) => Promise<void>
|
||||
}
|
||||
|
||||
export function PythonMirrorModal({
|
||||
open,
|
||||
mirror,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PythonMirrorModalProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<PythonMirrorFormValues>({
|
||||
resolver: zodResolver(mirrorSchema),
|
||||
defaultValues: emptyValues(),
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
reset(mirror ? toValues(mirror) : emptyValues())
|
||||
setSubmitError(null)
|
||||
}, [mirror, open, reset])
|
||||
|
||||
const requestClose = () => {
|
||||
if (!saving) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : '保存镜像失败')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
title={mirror ? '编辑 Python 镜像' : '新增 Python 镜像'}
|
||||
size="md"
|
||||
closeOnOverlay={!saving}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="python-mirror-form"
|
||||
loading={saving}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="python-mirror-form"
|
||||
className="mirror-form"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
{submitError ? (
|
||||
<Notice tone="error" title="保存失败">
|
||||
{submitError}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<FormField label="名称" htmlFor="mirror-name" error={errors.name?.message}>
|
||||
<Input
|
||||
id="mirror-name"
|
||||
{...register('name')}
|
||||
placeholder="例如:清华 TUNA"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="索引地址"
|
||||
htmlFor="mirror-url"
|
||||
hint="pip 的 index-url,例如 https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
error={errors.index_url?.message}
|
||||
>
|
||||
<Input
|
||||
id="mirror-url"
|
||||
{...register('index_url')}
|
||||
className="mono-input"
|
||||
placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.index_url)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="信任主机(可选)"
|
||||
htmlFor="mirror-trusted-host"
|
||||
hint="只有 http 源需要;https 源留空。会注入 PIP_TRUSTED_HOST"
|
||||
error={errors.trusted_host?.message}
|
||||
>
|
||||
<Input
|
||||
id="mirror-trusted-host"
|
||||
{...register('trusted_host')}
|
||||
className="mono-input"
|
||||
placeholder="mirrors.aliyun.com"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.trusted_host)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
label={
|
||||
<span className="mirror-enabled-copy">
|
||||
<Text strong size="sm">
|
||||
启用此镜像
|
||||
</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
停用后不会参与默认镜像选择
|
||||
</Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Notice,
|
||||
Result,
|
||||
Space,
|
||||
Spinner,
|
||||
Table,
|
||||
Tag,
|
||||
Text,
|
||||
TextButton,
|
||||
Title,
|
||||
toast,
|
||||
type TableColumn,
|
||||
} from '@stash/ui'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createPythonMirror,
|
||||
deletePythonMirror,
|
||||
listPythonMirrors,
|
||||
setDefaultPythonMirror,
|
||||
updatePythonMirror,
|
||||
type PythonMirror,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import {
|
||||
PythonMirrorModal,
|
||||
type PythonMirrorFormValues,
|
||||
} from '../components/PythonMirrorModal'
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称', width: 180 },
|
||||
{ key: 'index_url', title: '索引地址', width: 330 },
|
||||
{ key: 'trusted_host', title: '信任主机', width: 130 },
|
||||
{ key: 'status', title: '状态', width: 100 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 220 },
|
||||
]
|
||||
|
||||
function toRequest(values: PythonMirrorFormValues) {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
index_url: values.index_url.trim(),
|
||||
trusted_host: values.trusted_host.trim() || null,
|
||||
enabled: values.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 包镜像管理。
|
||||
*
|
||||
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
|
||||
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
|
||||
*/
|
||||
export function PythonMirrorsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<PythonMirror | null>(null)
|
||||
|
||||
const mirrorsQuery = useQuery({
|
||||
queryKey: ['python-mirrors'],
|
||||
queryFn: ({ signal }) => listPythonMirrors(signal),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
mirror,
|
||||
values,
|
||||
}: {
|
||||
mirror: PythonMirror | null
|
||||
values: PythonMirrorFormValues
|
||||
}) =>
|
||||
mirror
|
||||
? updatePythonMirror(mirror.id, toRequest(values))
|
||||
: createPythonMirror(toRequest(values)),
|
||||
onSuccess: (mirror, variables) => {
|
||||
setEditorOpen(false)
|
||||
setEditingMirror(null)
|
||||
toast.success(
|
||||
variables.mirror ? `已更新 ${mirror.name}` : `已添加 ${mirror.name}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
})
|
||||
|
||||
const defaultMutation = useMutation({
|
||||
mutationFn: (mirror: PythonMirror) => setDefaultPythonMirror(mirror.id),
|
||||
onSuccess: (mirror) => {
|
||||
toast.success(`默认镜像已切换为 ${mirror.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (mirror: PythonMirror) => deletePythonMirror(mirror.id),
|
||||
onSuccess: (_data, mirror) => {
|
||||
setDeleteTarget(null)
|
||||
toast.success(`已删除 ${mirror.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const mirrors = mirrorsQuery.data ?? []
|
||||
const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingMirror(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (mirror: PythonMirror) => {
|
||||
setEditingMirror(mirror)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const rows = mirrors.map((mirror) => ({
|
||||
id: mirror.id,
|
||||
name: (
|
||||
<Space gap="xs" align="center" wrap>
|
||||
<Text strong>{mirror.name}</Text>
|
||||
{mirror.is_default ? (
|
||||
<Tag tone="primary" shape="rounded">
|
||||
默认
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
index_url: (
|
||||
<code className="code-chip mirror-url" title={mirror.index_url}>
|
||||
{mirror.index_url}
|
||||
</code>
|
||||
),
|
||||
trusted_host: mirror.trusted_host ? (
|
||||
<Text className="mono" size="sm">
|
||||
{mirror.trusted_host}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
status: (
|
||||
<Tag tone={mirror.enabled ? 'success' : 'neutral'} dot>
|
||||
{mirror.enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
actions: (
|
||||
<Space justify="end" gap="xs" wrap>
|
||||
<TextButton
|
||||
disabled={mirror.is_default}
|
||||
onClick={() => defaultMutation.mutate(mirror)}
|
||||
>
|
||||
设为默认
|
||||
</TextButton>
|
||||
<TextButton onClick={() => openEdit(mirror)}>编辑</TextButton>
|
||||
<TextButton tone="danger" onClick={() => setDeleteTarget(mirror)}>
|
||||
删除
|
||||
</TextButton>
|
||||
</Space>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader
|
||||
eyebrow="PYTHON / PACKAGE MIRROR"
|
||||
title="Python 镜像"
|
||||
subtitle="管理 pip / uv 的包索引;默认镜像会在脚本任务运行时注入环境变量。"
|
||||
actions={<Button onClick={openCreate}>+ 新增镜像</Button>}
|
||||
/>
|
||||
|
||||
<Card padding="md" className="mirror-summary">
|
||||
<div className="mirror-summary-main">
|
||||
<Text size="xs" tone="muted">
|
||||
当前默认镜像
|
||||
</Text>
|
||||
<Title level={2}>
|
||||
{defaultMirror?.name ?? '未设置默认镜像'}
|
||||
</Title>
|
||||
<Text className="mono" size="sm" tone="sub">
|
||||
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="mirror-summary-meta">
|
||||
<Text size="xs" tone="muted">
|
||||
运行时注入
|
||||
</Text>
|
||||
<Space gap="xs" wrap>
|
||||
<Tag shape="rounded">PIP_INDEX_URL</Tag>
|
||||
<Tag shape="rounded">UV_INDEX_URL</Tag>
|
||||
{defaultMirror?.trusted_host ? (
|
||||
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Notice title="镜像只影响任务运行,不会自动安装依赖">
|
||||
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
|
||||
优先。修改后对下一次运行生效。
|
||||
</Notice>
|
||||
|
||||
<Card padding="none" className="mirror-card">
|
||||
{mirrorsQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="镜像列表加载失败"
|
||||
description={mirrorsQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void mirrorsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : mirrorsQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取镜像" />
|
||||
<Text tone="sub">正在读取镜像...</Text>
|
||||
</div>
|
||||
) : mirrors.length === 0 ? (
|
||||
<Empty
|
||||
className="empty-state"
|
||||
text="还没有 Python 镜像"
|
||||
action={<Button onClick={openCreate}>新增镜像</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="mirror-table"
|
||||
columns={TABLE_COLUMNS}
|
||||
rows={rows}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<PythonMirrorModal
|
||||
open={editorOpen}
|
||||
mirror={editingMirror}
|
||||
saving={saveMutation.isPending}
|
||||
onClose={() => {
|
||||
if (!saveMutation.isPending) {
|
||||
setEditorOpen(false)
|
||||
}
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
await saveMutation.mutateAsync({ mirror: editingMirror, values })
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除镜像 ${deleteTarget?.name ?? ''}`}
|
||||
description="删除后如果它正是默认镜像,Python 任务将不再注入镜像环境变量;正在运行的任务不受影响。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type TaskFilters,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { RunLogDrawer } from '../components/RunLogDrawer'
|
||||
import { EnabledBadge } from '../components/StatusBadge'
|
||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||
@@ -68,12 +69,12 @@ const ACTION_TYPE_OPTIONS = [
|
||||
]
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称 / 任务标识', width: 250 },
|
||||
{ key: 'timezone', title: '时区', width: 120 },
|
||||
{ key: 'schedule', title: '调度周期', width: 210 },
|
||||
{ key: 'status', title: '状态', width: 110 },
|
||||
{ key: 'timeout', title: '超时', width: 90 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 260 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
export function TaskDashboard() {
|
||||
@@ -322,17 +323,13 @@ export function TaskDashboard() {
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-title">
|
||||
<p className="eyebrow">TASKPILOT / WINDOWS SCHEDULER</p>
|
||||
<Title level={1}>TaskPilot</Title>
|
||||
<Text tone="sub">Rust 本地轻量化任务引擎 · Windows 原生调度中心</Text>
|
||||
</div>
|
||||
<Space gap="sm" wrap>
|
||||
<Button onClick={openCreate}>+ 新建任务</Button>
|
||||
</Space>
|
||||
</header>
|
||||
<div className="page-stack">
|
||||
<PageHeader
|
||||
eyebrow="TASKPILOT / WINDOWS SCHEDULER"
|
||||
title="任务调度"
|
||||
subtitle="Rust 本地轻量化任务引擎 · Windows 原生调度中心"
|
||||
actions={<Button onClick={openCreate}>+ 新建任务</Button>}
|
||||
/>
|
||||
|
||||
{actionError ? (
|
||||
<Notice
|
||||
@@ -529,7 +526,7 @@ export function TaskDashboard() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user