feat: 编辑器实时探测并显示已关联虚拟环境

fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
2026-10-24 22:30:10 +08:00
parent 23565b40a4
commit dd7612bc74
20 changed files with 1508 additions and 48 deletions
+27
View File
@@ -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: '+ 添加参数',
})
+53
View File
@@ -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;
+53 -1
View File
@@ -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
View File
@@ -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,
})
}
+77
View File
@@ -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;
+155 -25
View File
@@ -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 {
+2
View File
@@ -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,