feat: 工作目录支持页面内目录浏览器
This commit is contained in:
@@ -73,7 +73,25 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
})
|
||||
|
||||
const workingDir = createVenvFixture()
|
||||
await newTaskDialog.getByLabel(/项目工作目录/).fill(workingDir)
|
||||
const workingDirInput = newTaskDialog.getByLabel(/项目工作目录/)
|
||||
// Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。
|
||||
await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible()
|
||||
await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click()
|
||||
const directoryPicker = page.getByRole('dialog', { name: '选择项目工作目录' })
|
||||
await expect(directoryPicker).toBeVisible()
|
||||
const directoryPathInput = directoryPicker.getByLabel('目录路径')
|
||||
await directoryPathInput.fill(workingDir)
|
||||
await directoryPathInput.press('Enter')
|
||||
await expect(
|
||||
directoryPicker.getByRole('button', { name: '使用此目录' }),
|
||||
).toBeEnabled()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-directory-picker.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await directoryPicker.getByRole('button', { name: '使用此目录' }).click()
|
||||
await expect(directoryPicker).toBeHidden()
|
||||
await expect(workingDirInput).toHaveValue(workingDir)
|
||||
await expect(
|
||||
newTaskDialog.locator('.env-status-badge.ok code'),
|
||||
).toHaveText('.venv\\Scripts\\python.exe')
|
||||
|
||||
@@ -767,6 +767,24 @@ code {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.field-with-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-with-action .field-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pick-directory-button {
|
||||
flex-shrink: 0;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-detector-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -986,6 +1004,108 @@ code {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.directory-picker {
|
||||
display: flex;
|
||||
width: min(760px, 100%);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.directory-picker-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
}
|
||||
|
||||
.directory-path-form {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.directory-picker-body {
|
||||
max-height: 46vh;
|
||||
min-height: 220px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.directory-list {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.directory-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.directory-row:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.directory-row:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.directory-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.directory-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
background: #fafafb;
|
||||
}
|
||||
|
||||
.directory-picker-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.directory-picker-summary code {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-picker-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
+190
-2
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -44,7 +44,11 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
})
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
interface FetchMockOptions {
|
||||
directories?: (path: string | null) => Response
|
||||
}
|
||||
|
||||
function createFetchMock(options: FetchMockOptions = {}) {
|
||||
return vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
@@ -89,6 +93,19 @@ function createFetchMock() {
|
||||
: '未检测到虚拟环境,将直接使用系统解释器',
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/system/directories')) {
|
||||
const requestedPath = new URL(url, 'http://127.0.0.1').searchParams.get(
|
||||
'path',
|
||||
)
|
||||
return options.directories
|
||||
? options.directories(requestedPath)
|
||||
: jsonResponse({
|
||||
path: null,
|
||||
parent: null,
|
||||
entries: [],
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
|
||||
return jsonResponse(
|
||||
{
|
||||
@@ -212,4 +229,175 @@ describe('TaskDashboard', () => {
|
||||
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('browses folders in the page and fills the working directory', async () => {
|
||||
const listings: Record<string, unknown> = {
|
||||
'': {
|
||||
path: null,
|
||||
parent: null,
|
||||
truncated: false,
|
||||
entries: [{ name: 'D:\\', path: 'D:\\' }],
|
||||
},
|
||||
'D:\\': {
|
||||
path: 'D:\\',
|
||||
parent: null,
|
||||
truncated: false,
|
||||
entries: [{ name: 'Projects', path: 'D:\\Projects' }],
|
||||
},
|
||||
'D:\\Projects': {
|
||||
path: 'D:\\Projects',
|
||||
parent: 'D:\\',
|
||||
truncated: false,
|
||||
entries: [{ name: 'sync', path: 'D:\\Projects\\sync' }],
|
||||
},
|
||||
'D:\\Projects\\sync': {
|
||||
path: 'D:\\Projects\\sync',
|
||||
parent: 'D:\\Projects',
|
||||
truncated: false,
|
||||
entries: [],
|
||||
},
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
directories: (path) =>
|
||||
jsonResponse(
|
||||
listings[path ?? ''] ?? {
|
||||
code: 'DIRECTORY_NOT_FOUND',
|
||||
message: '目录不存在',
|
||||
details: {},
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
})
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'sync' }))
|
||||
expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: '使用此目录' }))
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull()
|
||||
expect(screen.getByLabelText(/项目工作目录/)).toHaveValue(
|
||||
'D:\\Projects\\sync',
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to the parent folder and back to the drive list', async () => {
|
||||
const listings: Record<string, unknown> = {
|
||||
'': {
|
||||
path: null,
|
||||
parent: null,
|
||||
truncated: false,
|
||||
entries: [{ name: 'D:\\', path: 'D:\\' }],
|
||||
},
|
||||
'D:\\': {
|
||||
path: 'D:\\',
|
||||
parent: null,
|
||||
truncated: false,
|
||||
entries: [{ name: 'Projects', path: 'D:\\Projects' }],
|
||||
},
|
||||
'D:\\Projects': {
|
||||
path: 'D:\\Projects',
|
||||
parent: 'D:\\',
|
||||
truncated: false,
|
||||
entries: [],
|
||||
},
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
directories: (path) => jsonResponse(listings[path ?? '']),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
})
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
||||
expect(
|
||||
await within(dialog).findByText('该目录下没有子文件夹'),
|
||||
).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: '上一级' }))
|
||||
expect(
|
||||
await within(dialog).findByRole('button', { name: 'Projects' }),
|
||||
).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: '盘符' }))
|
||||
expect(
|
||||
await within(dialog).findByRole('button', { name: 'D:\\' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the typed path when the directory picker is cancelled', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
directories: () =>
|
||||
jsonResponse({ path: null, parent: null, entries: [], truncated: false }),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
const input = screen.getByLabelText(/项目工作目录/)
|
||||
await user.type(input, 'D:\\Manual\\path')
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
})
|
||||
await user.click(within(dialog).getByRole('button', { name: '取消' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull(),
|
||||
)
|
||||
expect(input).toHaveValue('D:\\Manual\\path')
|
||||
})
|
||||
|
||||
it('shows the listing error inside the directory picker', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
directories: () =>
|
||||
jsonResponse(
|
||||
{
|
||||
code: 'DIRECTORY_NOT_FOUND',
|
||||
message: '目录不存在: Z:\\nope',
|
||||
details: {},
|
||||
},
|
||||
400,
|
||||
),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.type(screen.getByLabelText(/项目工作目录/), 'Z:\\nope')
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
})
|
||||
expect(
|
||||
await within(dialog).findByText('目录不存在: Z:\\nope'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '使用此目录' }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,9 @@ export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
||||
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
||||
export type VenvSource = components['schemas']['VenvSource']
|
||||
export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||
export type DirectoryEntry = components['schemas']['DirectoryEntry']
|
||||
export type DirectoryListingResponse =
|
||||
components['schemas']['DirectoryListingResponse']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -248,3 +251,17 @@ export function probePythonVenv(
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||
*/
|
||||
export function listDirectories(
|
||||
path?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DirectoryListingResponse> {
|
||||
return requestJson<DirectoryListingResponse>(
|
||||
withQuery('/api/v1/system/directories', { path }),
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,6 +148,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/system/directories": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_directories"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -271,6 +287,21 @@ export interface components {
|
||||
runs: string[];
|
||||
timezone: string;
|
||||
};
|
||||
DirectoryEntry: {
|
||||
/** @description 用于展示的名称 */
|
||||
name: string;
|
||||
/** @description 绝对路径,选中后回填到表单 */
|
||||
path: string;
|
||||
};
|
||||
DirectoryListingResponse: {
|
||||
entries: components["schemas"]["DirectoryEntry"][];
|
||||
/** @description 上一级目录;盘符根目录和盘符列表为 null */
|
||||
parent?: string | null;
|
||||
/** @description 当前目录;为 null 表示正在列出盘符 */
|
||||
path?: string | null;
|
||||
/** @description 条目超过上限被截断时为 true */
|
||||
truncated: boolean;
|
||||
};
|
||||
ErrorResponse: {
|
||||
code: string;
|
||||
details: {
|
||||
@@ -707,6 +738,38 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_directories: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description 绝对目录路径;留空表示列出盘符 */
|
||||
path?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Subdirectories of the requested path, or the drive list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DirectoryListingResponse"];
|
||||
};
|
||||
};
|
||||
/** @description The path is not an existing absolute directory */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_tasks: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { listDirectories } from '../api/client'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
open: boolean
|
||||
initialPath: string
|
||||
onSelect: (path: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
|
||||
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
|
||||
*/
|
||||
export function DirectoryPickerDialog({
|
||||
open,
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: DirectoryPickerDialogProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
||||
const [draftPath, setDraftPath] = useState('')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef, '[data-picker-path]')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const start = initialPath.trim()
|
||||
setCurrentPath(start || null)
|
||||
setDraftPath(start)
|
||||
}, [initialPath, open])
|
||||
|
||||
const listing = useQuery({
|
||||
queryKey: ['directories', currentPath],
|
||||
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
|
||||
enabled: open,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (listing.data?.path) {
|
||||
setDraftPath(listing.data.path)
|
||||
}
|
||||
}, [listing.data?.path])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = listing.data?.entries ?? []
|
||||
const currentDirectory = listing.data?.path ?? null
|
||||
const parent = listing.data?.parent ?? null
|
||||
|
||||
const navigate = (path: string | null) => {
|
||||
setCurrentPath(path)
|
||||
setDraftPath(path ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="modal-card directory-picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="directory-picker-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">WORKING DIRECTORY</p>
|
||||
<h2 id="directory-picker-title">选择项目工作目录</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭目录选择"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="directory-picker-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => navigate(parent)}
|
||||
disabled={!parent}
|
||||
>
|
||||
上一级
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => navigate(null)}
|
||||
>
|
||||
盘符
|
||||
</button>
|
||||
<form
|
||||
className="directory-path-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigate(draftPath.trim() || null)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="field-input mono"
|
||||
value={draftPath}
|
||||
onChange={(event) => setDraftPath(event.target.value)}
|
||||
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
||||
aria-label="目录路径"
|
||||
data-picker-path
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="directory-picker-body">
|
||||
{listing.isPending ? (
|
||||
<p className="muted-copy">正在读取目录…</p>
|
||||
) : null}
|
||||
{listing.isError ? (
|
||||
<div className="inline-alert inline-alert-error" role="alert">
|
||||
{listing.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||
<p className="empty-inline">
|
||||
{currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'}
|
||||
</p>
|
||||
) : null}
|
||||
{entries.length > 0 ? (
|
||||
<ul className="directory-list">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="directory-row"
|
||||
onClick={() => navigate(entry.path)}
|
||||
title={entry.path}
|
||||
>
|
||||
<span className="directory-icon" aria-hidden="true">
|
||||
📁
|
||||
</span>
|
||||
<span className="directory-name">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{listing.data?.truncated ? (
|
||||
<p className="field-hint">文件夹过多,仅列出前 500 个。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="directory-picker-footer">
|
||||
<div className="directory-picker-summary">
|
||||
<span className="section-label">将使用</span>
|
||||
<code>{currentDirectory ?? '尚未选择目录'}</code>
|
||||
</div>
|
||||
<div className="modal-actions directory-picker-actions">
|
||||
<button type="button" className="button button-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary"
|
||||
disabled={!currentDirectory}
|
||||
onClick={() => {
|
||||
if (currentDirectory) {
|
||||
onSelect(currentDirectory)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
使用此目录
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type Task,
|
||||
type VenvProbeResponse,
|
||||
} from '../api/client'
|
||||
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
|
||||
import {
|
||||
createEmptyTaskFormValues,
|
||||
mapServerFieldToFormField,
|
||||
@@ -42,8 +43,10 @@ export function TaskEditorModal({
|
||||
}: TaskEditorModalProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef, '[data-dialog-autofocus]')
|
||||
// 目录选择弹窗打开时让出焦点管理,避免两个焦点陷阱互相争夺 Tab。
|
||||
useFocusTrap(open && !pickerOpen, dialogRef, '[data-dialog-autofocus]')
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
@@ -51,6 +54,7 @@ export function TaskEditorModal({
|
||||
reset,
|
||||
watch,
|
||||
setError,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<TaskFormValues>({
|
||||
resolver: zodResolver(taskFormSchema),
|
||||
@@ -119,6 +123,7 @@ export function TaskEditorModal({
|
||||
const previewVirtualEnv =
|
||||
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
||||
const previewInterpreter = linkedVenv?.display_path ?? null
|
||||
|
||||
const scriptArgs = useFieldArray({ control, name: 'script.args' })
|
||||
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
||||
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
|
||||
@@ -132,6 +137,7 @@ export function TaskEditorModal({
|
||||
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
|
||||
setCronPreviewCount(5)
|
||||
setSubmitError(null)
|
||||
setPickerOpen(false)
|
||||
}, [open, reset, task])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -346,16 +352,35 @@ export function TaskEditorModal({
|
||||
|
||||
{actionType === 'script' ? (
|
||||
<div className="dynamic-fields">
|
||||
<Field
|
||||
label="项目工作目录 (Working Directory)"
|
||||
error={errors.script?.working_dir?.message}
|
||||
>
|
||||
<input
|
||||
{...register('script.working_dir')}
|
||||
className="field-input mono"
|
||||
placeholder="D:\Projects\sync"
|
||||
/>
|
||||
</Field>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="task-working-dir">
|
||||
项目工作目录 (Working Directory)
|
||||
</label>
|
||||
<div className="field-with-action">
|
||||
<input
|
||||
id="task-working-dir"
|
||||
{...register('script.working_dir')}
|
||||
className="field-input mono"
|
||||
placeholder="D:\Projects\sync"
|
||||
aria-invalid={Boolean(errors.script?.working_dir)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary pick-directory-button"
|
||||
// 不夺走输入框焦点:否则 blur 校验插入错误行会让表单瞬间下移,
|
||||
// 点击可能落到移位后的其它元素上。
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
选择文件夹…
|
||||
</button>
|
||||
</div>
|
||||
{errors.script?.working_dir?.message ? (
|
||||
<span className="field-error">
|
||||
{errors.script.working_dir.message}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<VenvDetectorBar
|
||||
workingDir={debouncedWorkingDir}
|
||||
probe={venvProbe.data}
|
||||
@@ -643,6 +668,18 @@ export function TaskEditorModal({
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<DirectoryPickerDialog
|
||||
open={pickerOpen}
|
||||
initialPath={scriptPreview.working_dir}
|
||||
onSelect={(path) => {
|
||||
setValue('script.working_dir', path, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user