feat: 编辑器实时探测并显示已关联虚拟环境
fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const venvProject = fileURLToPath(
|
||||
new URL('../../.e2e-data/venv-project', import.meta.url),
|
||||
)
|
||||
|
||||
function createVenvFixture(): string {
|
||||
const scripts = join(venvProject, '.venv', 'Scripts')
|
||||
mkdirSync(scripts, { recursive: true })
|
||||
writeFileSync(join(venvProject, '.venv', 'pyvenv.cfg'), 'home = C:\\Python\n')
|
||||
writeFileSync(join(scripts, 'python.exe'), '')
|
||||
return venvProject
|
||||
}
|
||||
|
||||
const taskPayload = {
|
||||
id: 'e2e_echo',
|
||||
name: 'E2E echo',
|
||||
@@ -56,6 +71,18 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
path: 'test-results/dashboard-task-editor.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
const workingDir = createVenvFixture()
|
||||
await newTaskDialog.getByLabel(/项目工作目录/).fill(workingDir)
|
||||
await expect(
|
||||
newTaskDialog.locator('.env-status-badge.ok code'),
|
||||
).toHaveText('.venv\\Scripts\\python.exe')
|
||||
await expect(newTaskDialog.getByText(/已关联虚拟环境/)).toBeVisible()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-venv-detected.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
const addArgumentButton = newTaskDialog.getByRole('button', {
|
||||
name: '+ 添加参数',
|
||||
})
|
||||
|
||||
@@ -767,6 +767,59 @@ code {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.env-detector-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px 12px;
|
||||
margin-top: -1px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.env-status-badge {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.env-status-badge.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.env-status-badge.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.env-status-badge.pending {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.env-status-badge code {
|
||||
overflow: hidden;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--badge-bg);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-detector-hint {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.field-input[aria-invalid='true'] {
|
||||
border-color: #f08aa0;
|
||||
background: #fffafb;
|
||||
|
||||
@@ -73,6 +73,22 @@ function createFetchMock() {
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/venv')) {
|
||||
const probe = JSON.parse(String(init?.body)) as {
|
||||
working_dir: string
|
||||
}
|
||||
const linked = probe.working_dir.toLowerCase().includes('sync')
|
||||
return jsonResponse({
|
||||
status: linked ? 'linked' : 'missing',
|
||||
interpreter_kind: 'python',
|
||||
venv_path: linked ? 'D:\\Projects\\sync\\.venv' : null,
|
||||
display_path: linked ? '.venv\\Scripts\\python.exe' : 'python.exe',
|
||||
source: linked ? 'working_directory' : null,
|
||||
message: linked
|
||||
? '已自动关联工作目录中的虚拟环境'
|
||||
: '未检测到虚拟环境,将直接使用系统解释器',
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
|
||||
return jsonResponse(
|
||||
{
|
||||
@@ -136,7 +152,7 @@ describe('TaskDashboard', () => {
|
||||
await user.clear(screen.getByLabelText(/任务标识/))
|
||||
await user.type(screen.getByLabelText(/任务标识/), 'new_task')
|
||||
await user.type(screen.getByLabelText('名称'), 'New task')
|
||||
await user.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new')
|
||||
await user.type(screen.getByLabelText(/项目工作目录/), 'D:\\Projects\\new')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '保存并热刷新' }),
|
||||
@@ -160,4 +176,40 @@ describe('TaskDashboard', () => {
|
||||
expect(payload.action.type).toBe('script')
|
||||
expect(payload.action.working_dir).toBe('D:\\Projects\\new')
|
||||
})
|
||||
|
||||
it('shows the virtual environment detected for the working directory', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.type(screen.getByLabelText(/项目工作目录/), 'D:\\Projects\\sync')
|
||||
|
||||
expect(
|
||||
await screen.findByText(/已关联虚拟环境/, {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('.venv\\Scripts\\python.exe')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Rust 自动前置 PATH 及 VIRTUAL_ENV'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the system interpreter when no venv is detected', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.type(
|
||||
screen.getByLabelText(/项目工作目录/),
|
||||
'D:\\Projects\\plain',
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,11 @@ export type ErrorResponse = components['schemas']['ErrorResponse']
|
||||
export type SessionResponse = components['schemas']['SessionResponse']
|
||||
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
||||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
||||
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
|
||||
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 interface TaskFilters {
|
||||
q?: string
|
||||
@@ -232,3 +237,14 @@ export function previewCron(
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function probePythonVenv(
|
||||
request: VenvProbeRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<VenvProbeResponse> {
|
||||
return requestJson<VenvProbeResponse>('/api/v1/python/venv', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/venv": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["probe_python_venv"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/runs/{run_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -273,6 +289,8 @@ export interface components {
|
||||
};
|
||||
/** @enum {string} */
|
||||
HttpMethod: "GET" | "POST";
|
||||
/** @enum {string} */
|
||||
InterpreterKind: "python" | "node" | "other";
|
||||
LogChunkResponse: {
|
||||
complete: boolean;
|
||||
content: string;
|
||||
@@ -389,6 +407,32 @@ export interface components {
|
||||
timeout_secs?: number;
|
||||
timezone?: string;
|
||||
};
|
||||
/** @description 编辑器在用户填写工作目录时发起的探测请求。 */
|
||||
VenvProbeRequest: {
|
||||
/** @description 解释器或可执行程序,对应 `action.interpreter` */
|
||||
interpreter: string;
|
||||
/** @description 手动填写的虚拟环境目录,对应 `action.venv` */
|
||||
venv?: string | null;
|
||||
/** @description 任务工作目录,对应 `action.working_dir` */
|
||||
working_dir: string;
|
||||
};
|
||||
VenvProbeResponse: {
|
||||
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
||||
display_path?: string | null;
|
||||
interpreter_kind: components["schemas"]["InterpreterKind"];
|
||||
message: string;
|
||||
source?: components["schemas"]["VenvSource"] | null;
|
||||
status: components["schemas"]["VenvProbeStatus"];
|
||||
/** @description 虚拟环境根目录的绝对路径 */
|
||||
venv_path?: string | null;
|
||||
};
|
||||
/** @enum {string} */
|
||||
VenvProbeStatus: "linked" | "invalid" | "missing" | "not_applicable" | "incomplete";
|
||||
/**
|
||||
* @description 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。
|
||||
* @enum {string}
|
||||
*/
|
||||
VenvSource: "configured" | "interpreter" | "working_directory";
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -471,6 +515,39 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
probe_python_venv: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["VenvProbeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Detected Python virtual environment */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VenvProbeResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid venv probe request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_run: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from 'react-hook-form'
|
||||
import { ApiRequestError, previewCron, type Task } from '../api/client'
|
||||
import {
|
||||
ApiRequestError,
|
||||
previewCron,
|
||||
probePythonVenv,
|
||||
type Task,
|
||||
type VenvProbeResponse,
|
||||
} from '../api/client'
|
||||
import {
|
||||
createEmptyTaskFormValues,
|
||||
mapServerFieldToFormField,
|
||||
@@ -57,6 +63,15 @@ export function TaskEditorModal({
|
||||
const scriptPreview = watch('script')
|
||||
const debouncedCron = useDebouncedValue(cronValue, 300)
|
||||
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
||||
const debouncedWorkingDir = useDebouncedValue(
|
||||
scriptPreview.working_dir ?? '',
|
||||
350,
|
||||
)
|
||||
const debouncedInterpreter = useDebouncedValue(
|
||||
scriptPreview.interpreter ?? '',
|
||||
350,
|
||||
)
|
||||
const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350)
|
||||
const cronPreview = useQuery({
|
||||
queryKey: [
|
||||
'cron-preview',
|
||||
@@ -79,6 +94,31 @@ export function TaskEditorModal({
|
||||
Boolean(debouncedTimezone.trim()),
|
||||
retry: false,
|
||||
})
|
||||
const venvProbe = useQuery({
|
||||
queryKey: [
|
||||
'python-venv',
|
||||
debouncedWorkingDir,
|
||||
debouncedInterpreter,
|
||||
debouncedVenv,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
probePythonVenv(
|
||||
{
|
||||
working_dir: debouncedWorkingDir,
|
||||
interpreter: debouncedInterpreter,
|
||||
venv: debouncedVenv.trim() || null,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
enabled:
|
||||
open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()),
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
|
||||
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' })
|
||||
@@ -306,17 +346,23 @@ 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>
|
||||
<VenvDetectorBar
|
||||
workingDir={debouncedWorkingDir}
|
||||
probe={venvProbe.data}
|
||||
pending={venvProbe.isPending}
|
||||
failed={venvProbe.isError}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="工作目录"
|
||||
error={errors.script?.working_dir?.message}
|
||||
>
|
||||
<input
|
||||
{...register('script.working_dir')}
|
||||
className="field-input mono"
|
||||
placeholder="D:\Projects\sync"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="解释器"
|
||||
error={errors.script?.interpreter?.message}
|
||||
@@ -327,17 +373,17 @@ export function TaskEditorModal({
|
||||
placeholder="python.exe"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="虚拟环境目录 (可选)"
|
||||
hint="留空时自动探测;填写后优先使用该目录"
|
||||
>
|
||||
<input
|
||||
{...register('script.venv')}
|
||||
className="field-input mono"
|
||||
placeholder="例如:.venv"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="虚拟环境目录 (可选)"
|
||||
hint="留空时自动从工作目录及上级目录探测 .venv、venv、env"
|
||||
>
|
||||
<input
|
||||
{...register('script.venv')}
|
||||
className="field-input mono"
|
||||
placeholder="例如:.venv"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="脚本文件或相对路径"
|
||||
error={errors.script?.script?.message}
|
||||
@@ -412,9 +458,9 @@ export function TaskEditorModal({
|
||||
<span>Windows 原生环境</span>
|
||||
</div>
|
||||
<div className="cli-preview-body">
|
||||
{scriptPreview.venv.trim() ? (
|
||||
{previewVirtualEnv ? (
|
||||
<div className="cli-line-env">
|
||||
set VIRTUAL_ENV={scriptPreview.venv.trim()}
|
||||
set VIRTUAL_ENV={previewVirtualEnv}
|
||||
</div>
|
||||
) : null}
|
||||
{scriptPreview.env
|
||||
@@ -431,7 +477,7 @@ export function TaskEditorModal({
|
||||
</div>
|
||||
))}
|
||||
<div className="cli-line-cmd">
|
||||
{formatInterpreterPreview(scriptPreview)}{' '}
|
||||
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
|
||||
{scriptPreview.script.trim() || 'main.py'}
|
||||
{formatArgumentPreview(scriptPreview.args)}
|
||||
</div>
|
||||
@@ -615,11 +661,95 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
||||
|
||||
function formatInterpreterPreview(
|
||||
script: TaskFormValues['script'],
|
||||
linkedPythonPath?: string | null,
|
||||
): string {
|
||||
if (script.venv.trim()) {
|
||||
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
||||
}
|
||||
return script.interpreter.trim() || 'python.exe'
|
||||
return linkedPythonPath ?? (script.interpreter.trim() || 'python.exe')
|
||||
}
|
||||
|
||||
interface VenvDetectorBarProps {
|
||||
workingDir: string
|
||||
probe: VenvProbeResponse | undefined
|
||||
pending: boolean
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
function VenvDetectorBar({
|
||||
workingDir,
|
||||
probe,
|
||||
pending,
|
||||
failed,
|
||||
}: VenvDetectorBarProps) {
|
||||
if (!workingDir.trim()) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge warn">
|
||||
● 请输入有效的工作目录以探测环境
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge pending">⏳ 正在探测解释器...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (failed || !probe) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (probe.status === 'linked') {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge ok">
|
||||
● 已关联虚拟环境: <code>{probe.display_path}</code>
|
||||
</span>
|
||||
<span className="env-detector-hint">
|
||||
Rust 自动前置 PATH 及 VIRTUAL_ENV
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (probe.status === 'not_applicable') {
|
||||
const label =
|
||||
probe.interpreter_kind === 'node' ? '识别为 Node.js' : '使用指定解释器'
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge ok">
|
||||
● {label}: <code>{probe.display_path}</code>
|
||||
</span>
|
||||
<span className="env-detector-hint">{probe.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (probe.status === 'missing') {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge ok">
|
||||
● 使用系统解释器: <code>{probe.display_path}</code>
|
||||
</span>
|
||||
<span className="env-detector-hint">{probe.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge warn">● {probe.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isSensitiveName(name: string): boolean {
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
--success-bg: #eefbf5;
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: #fff1f2;
|
||||
--warning: #d97706;
|
||||
--warning-bg: #fffbeb;
|
||||
--shadow: var(--shadow-card);
|
||||
--font-sans:
|
||||
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
|
||||
Reference in New Issue
Block a user