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
+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 {