Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a6e17531 | ||
|
|
c7a66c22b6 | ||
|
|
608872bf06 | ||
|
|
d3f0f57f6d | ||
|
|
b7ded69a7d | ||
|
|
b2ff255e32 | ||
|
|
369fc07b62 | ||
|
|
69d721055d |
+4
-1
@@ -110,7 +110,10 @@ pub async fn preview_cron(
|
||||
chrono::Utc::now(),
|
||||
request.count,
|
||||
)
|
||||
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
|
||||
.map_err(|error| {
|
||||
tracing::debug!(error = %error, "cron preview failed");
|
||||
ApiError::validation("expression", error.user_message())
|
||||
})?;
|
||||
Ok(Json(preview))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,27 @@ pub enum CronPreviewError {
|
||||
InvalidTimezone(String),
|
||||
}
|
||||
|
||||
impl CronPreviewError {
|
||||
/// 面向界面的中文说明。解析器原始错误(英文、带组件路径)只写日志,
|
||||
/// 不再直接抛给前端。
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
Self::InvalidExpression(detail) => {
|
||||
if detail.contains("Invalid step syntax") {
|
||||
"Cron 表达式步长写法不正确:不要用「0/2」这种单数字步长,通配步长写「*/2」,范围步长写「1-10/2」"
|
||||
.to_owned()
|
||||
} else {
|
||||
"Cron 表达式无效,请检查字段取值与步长写法(通配步长用 */n,范围步长用 a-b/n)"
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
Self::TooManyRuns => format!("一次最多预览 {MAX_PREVIEW_RUNS} 次运行"),
|
||||
Self::NoFutureOccurrence => "Cron 表达式没有可计算的未来执行时间".to_owned(),
|
||||
Self::InvalidTimezone(_) => "时区必须是有效的 IANA 时区名称".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CronPreviewRequest {
|
||||
|
||||
@@ -166,14 +166,15 @@ fn validate_common(
|
||||
})?;
|
||||
|
||||
let cron = Cron::from_str(cron_expression).map_err(|error| {
|
||||
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
|
||||
tracing::debug!(error = %error, "invalid cron expression");
|
||||
let message =
|
||||
crate::cron::CronPreviewError::InvalidExpression(error.to_string()).user_message();
|
||||
crate::error::ApiError::validation("cron", message)
|
||||
})?;
|
||||
cron.find_next_occurrence(&Utc::now(), false)
|
||||
.map_err(|error| {
|
||||
crate::error::ApiError::validation(
|
||||
"cron",
|
||||
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
|
||||
)
|
||||
tracing::debug!(error = %error, "cron has no future occurrence");
|
||||
crate::error::ApiError::validation("cron", "Cron 表达式没有可计算的未来执行时间")
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -142,6 +142,13 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
})
|
||||
await drawer.getByRole('button', { name: '关闭' }).click()
|
||||
|
||||
// 运行已结束时再打开日志,也要能回放(曾因 rAF 句柄未归零停在空态)。
|
||||
await page.getByRole('button', { name: '日志' }).click()
|
||||
const replayDrawer = page.getByRole('dialog', { name: 'E2E echo' })
|
||||
await expect(replayDrawer).toBeVisible()
|
||||
await expect(replayDrawer.getByText('e2e-output')).toBeVisible()
|
||||
await replayDrawer.getByRole('button', { name: '关闭' }).click()
|
||||
|
||||
await page.getByRole('button', { name: '编辑' }).click()
|
||||
const editor = page.getByRole('dialog', {
|
||||
name: '编辑任务 [e2e_echo]',
|
||||
|
||||
+86
-10
@@ -77,7 +77,8 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
/* 搜索框贴卡片左边缘,和页头 / 指标卡对齐;右侧给筛选留 16px 呼吸位 */
|
||||
padding: 12px 16px 12px 0;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
background-color: var(--su-bg-subtle);
|
||||
}
|
||||
@@ -105,9 +106,16 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-name-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
display: block;
|
||||
max-width: 240px;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -118,6 +126,10 @@
|
||||
font-family: var(--su-font-mono);
|
||||
}
|
||||
|
||||
.timezone-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -193,6 +205,21 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 弹窗内容仍可用滚轮 / 触控板滚动,但不显示右侧滚动条 */
|
||||
.task-editor-modal > div,
|
||||
.directory-picker-modal > div,
|
||||
.directory-picker-body {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.task-editor-modal > div::-webkit-scrollbar,
|
||||
.directory-picker-modal > div::-webkit-scrollbar,
|
||||
.directory-picker-body::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.task-editor-modal > header,
|
||||
.task-editor-modal > footer {
|
||||
flex: none;
|
||||
@@ -210,8 +237,9 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-grid-single {
|
||||
grid-template-columns: 1fr;
|
||||
/* 虚拟环境目录通常是一条完整路径,给它更多宽度;解释器名很短 */
|
||||
.venv-grid {
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@@ -235,15 +263,13 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cron-preview-box {
|
||||
.cron-preview-panel {
|
||||
display: flex;
|
||||
max-height: 160px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-sm);
|
||||
padding: 10px 12px;
|
||||
padding: 12px 14px;
|
||||
background-color: var(--su-bg-subtle);
|
||||
}
|
||||
|
||||
@@ -251,15 +277,42 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cron-preview-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cron-preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cron-preview-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cron-count-select {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
/* 未来运行时间按三列铺开:默认 5 条一眼看完,不需要内部滚动条 */
|
||||
.cron-run-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px 12px;
|
||||
}
|
||||
|
||||
.cron-run-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--su-text-secondary);
|
||||
@@ -269,22 +322,36 @@
|
||||
|
||||
.cron-run-index {
|
||||
width: 28px;
|
||||
flex: none;
|
||||
color: var(--su-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cron-run-time {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-xs);
|
||||
padding: 2px 8px;
|
||||
background-color: var(--su-bg-card);
|
||||
color: var(--su-text-main);
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cron-preview-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: var(--su-color-danger);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
}
|
||||
|
||||
.cron-preview-error i {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.env-detector-bar {
|
||||
@@ -832,6 +899,7 @@
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
@@ -852,6 +920,14 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.venv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cron-run-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.argument-row,
|
||||
.key-value-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -409,6 +409,10 @@ function RunLogOutput({
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
// StrictMode 会「挂载 → 清理 → 再挂载」,取消后必须把句柄归零,
|
||||
// 否则下一次 scheduleRender 会误以为还有帧在排队而不再安排渲染,
|
||||
// 已结束的 run(complete 在首帧就为 true)会一直停在「等待日志输出」。
|
||||
frameRef.current = 0
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -4,16 +4,18 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormField,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Spinner,
|
||||
Text,
|
||||
Textarea,
|
||||
TextButton,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
@@ -58,13 +60,7 @@ const HTTP_METHOD_OPTIONS = [
|
||||
{ value: 'POST', label: 'POST' },
|
||||
]
|
||||
|
||||
const TIMEZONE_SUGGESTIONS = [
|
||||
'Asia/Shanghai',
|
||||
'Asia/Hong_Kong',
|
||||
'UTC',
|
||||
'Europe/London',
|
||||
'America/New_York',
|
||||
]
|
||||
const TIMEZONE_OPTIONS = ['Asia/Shanghai']
|
||||
|
||||
const CRON_PREVIEW_COUNTS = [3, 5, 8, 10]
|
||||
|
||||
@@ -85,9 +81,13 @@ export function TaskEditorModal({
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||
const autoVenvRef = useRef('')
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
@@ -97,12 +97,20 @@ export function TaskEditorModal({
|
||||
} = useForm<TaskFormValues>({
|
||||
resolver: zodResolver(taskFormSchema),
|
||||
defaultValues: createEmptyTaskFormValues(),
|
||||
mode: 'onBlur',
|
||||
// 长表单在 blur 时插错误行会让下面所有控件瞬间位移,刚点下去的按钮
|
||||
// 可能在 mouseup 前被挪走(下拉框会「点了没反应」)。改为提交时校验,
|
||||
// 提交后仍按 onChange 实时修正。
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
const actionType = watch('actionType')
|
||||
const cronValue = watch('cron')
|
||||
const timezoneValue = watch('timezone')
|
||||
const scriptPreview = watch('script')
|
||||
// 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项,
|
||||
// 避免下拉显示成空值。
|
||||
const timezoneOptions = Array.from(
|
||||
new Set(timezoneValue ? [...TIMEZONE_OPTIONS, timezoneValue] : TIMEZONE_OPTIONS),
|
||||
).map((zone) => ({ value: zone, label: zone }))
|
||||
const debouncedCron = useDebouncedValue(cronValue, 300)
|
||||
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
||||
const debouncedWorkingDir = useDebouncedValue(
|
||||
@@ -176,8 +184,49 @@ export function TaskEditorModal({
|
||||
setCronPreviewCount(5)
|
||||
setSubmitError(null)
|
||||
setPickerOpen(false)
|
||||
autoVenvRef.current = ''
|
||||
}, [open, reset, task])
|
||||
|
||||
// 换工作目录时清掉上一轮自动填入的值(手动填写的值不动)。
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
if (autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current) {
|
||||
setValue('script.venv', '', { shouldDirty: false })
|
||||
}
|
||||
autoVenvRef.current = ''
|
||||
}, [debouncedWorkingDir, getValues, open, setValue])
|
||||
|
||||
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const probe = venvProbe.data
|
||||
if (!probe || probe.status !== 'linked' || !probe.venv_path) {
|
||||
return
|
||||
}
|
||||
const current = getValues('script.venv').trim()
|
||||
// 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」
|
||||
// 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。
|
||||
if (debouncedVenv.trim() !== current) {
|
||||
return
|
||||
}
|
||||
if (current && current !== autoVenvRef.current) {
|
||||
return
|
||||
}
|
||||
if (current !== '' && current === autoVenvRef.current) {
|
||||
return
|
||||
}
|
||||
const next = venvFieldValue(debouncedWorkingDir, probe.venv_path)
|
||||
autoVenvRef.current = next
|
||||
setValue('script.venv', next, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}, [debouncedVenv, debouncedWorkingDir, getValues, open, setValue, venvProbe.data])
|
||||
|
||||
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
||||
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
||||
useEffect(() => {
|
||||
@@ -286,44 +335,8 @@ export function TaskEditorModal({
|
||||
{...register('cron')}
|
||||
className="mono-input"
|
||||
placeholder="0 0 2 * * *"
|
||||
aria-invalid={Boolean(errors.cron)}
|
||||
aria-invalid={Boolean(errors.cron) || cronPreview.isError}
|
||||
/>
|
||||
<div className="cron-preview-box" aria-live="polite">
|
||||
<div className="cron-preview-head">
|
||||
<Text size="xs" tone="muted">
|
||||
预览未来运行时间
|
||||
</Text>
|
||||
<Select
|
||||
className="cron-count-select"
|
||||
options={CRON_PREVIEW_COUNTS.map((count) => ({
|
||||
value: String(count),
|
||||
label: `${count} 次`,
|
||||
}))}
|
||||
value={String(cronPreviewCount)}
|
||||
onChange={(value) => setCronPreviewCount(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
{cronPreview.isFetching ? (
|
||||
<Text size="sm" tone="sub">
|
||||
正在计算...
|
||||
</Text>
|
||||
) : cronPreview.isError ? (
|
||||
<Text size="sm" className="cron-preview-error">
|
||||
{cronPreview.error.message}
|
||||
</Text>
|
||||
) : cronPreview.data?.runs.length ? (
|
||||
cronPreview.data.runs.map((run, index) => (
|
||||
<div className="cron-run-item" key={run}>
|
||||
<span className="cron-run-index">#{index + 1}</span>
|
||||
<span className="cron-run-time">{run}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" tone="sub">
|
||||
等待输入有效 Cron 表达式...
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
@@ -348,24 +361,82 @@ export function TaskEditorModal({
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid-single">
|
||||
<FormField
|
||||
label="IANA 时区"
|
||||
htmlFor="task-timezone"
|
||||
error={errors.timezone?.message}
|
||||
>
|
||||
<Input
|
||||
id="task-timezone"
|
||||
{...register('timezone')}
|
||||
list="timezone-options"
|
||||
placeholder="Asia/Shanghai"
|
||||
aria-invalid={Boolean(errors.timezone)}
|
||||
/>
|
||||
<datalist id="timezone-options">
|
||||
{TIMEZONE_SUGGESTIONS.map((zone) => (
|
||||
<option value={zone} key={zone} />
|
||||
<section
|
||||
className="cron-preview-panel"
|
||||
aria-label="未来运行时间"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="cron-preview-head">
|
||||
<div className="cron-preview-title">
|
||||
<Text strong size="sm">
|
||||
未来运行时间
|
||||
</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
按当前 Cron 与时区实时计算
|
||||
</Text>
|
||||
</div>
|
||||
<div className="cron-preview-actions">
|
||||
<Text size="xs" tone="muted">
|
||||
预览
|
||||
</Text>
|
||||
<Select
|
||||
className="cron-count-select"
|
||||
options={CRON_PREVIEW_COUNTS.map((count) => ({
|
||||
value: String(count),
|
||||
label: `${count} 次`,
|
||||
}))}
|
||||
value={String(cronPreviewCount)}
|
||||
onChange={(value) => setCronPreviewCount(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cronPreview.isFetching ? (
|
||||
<div className="cron-preview-status">
|
||||
<Spinner size="sm" label="正在计算" />
|
||||
<Text size="sm" tone="sub">
|
||||
正在计算...
|
||||
</Text>
|
||||
</div>
|
||||
) : cronPreview.isError ? (
|
||||
<div className="cron-preview-error">
|
||||
<Icon name="warning_line" />
|
||||
<span>
|
||||
{cronPreview.error instanceof ApiRequestError
|
||||
? cronPreview.error.message
|
||||
: '暂时无法计算运行时间,请稍后重试'}
|
||||
</span>
|
||||
</div>
|
||||
) : cronPreview.data?.runs.length ? (
|
||||
<div className="cron-run-grid">
|
||||
{cronPreview.data.runs.map((run, index) => (
|
||||
<div className="cron-run-item" key={run}>
|
||||
<span className="cron-run-index">#{index + 1}</span>
|
||||
<span className="cron-run-time">{run}</span>
|
||||
</div>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm" tone="sub">
|
||||
等待输入有效 Cron 表达式...
|
||||
</Text>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="form-grid">
|
||||
<FormField label="IANA 时区" error={errors.timezone?.message}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
className="form-select"
|
||||
options={timezoneOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="任务类型 (Action Type)">
|
||||
@@ -411,14 +482,20 @@ export function TaskEditorModal({
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<VenvDetectorBar
|
||||
workingDir={debouncedWorkingDir}
|
||||
probe={venvProbe.data}
|
||||
pending={venvProbe.isPending}
|
||||
failed={venvProbe.isError}
|
||||
/>
|
||||
|
||||
<div className="form-grid">
|
||||
<div className="form-grid venv-grid">
|
||||
<FormField
|
||||
label="虚拟环境目录 (可选)"
|
||||
htmlFor="task-venv"
|
||||
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
|
||||
error={errors.script?.venv?.message}
|
||||
>
|
||||
<Input
|
||||
id="task-venv"
|
||||
{...register('script.venv')}
|
||||
className="mono-input"
|
||||
placeholder="例如:.venv"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="解释器"
|
||||
htmlFor="task-interpreter"
|
||||
@@ -431,21 +508,15 @@ export function TaskEditorModal({
|
||||
placeholder="python.exe"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="虚拟环境目录 (可选)"
|
||||
htmlFor="task-venv"
|
||||
hint="留空时自动探测;填写后优先使用该目录"
|
||||
error={errors.script?.venv?.message}
|
||||
>
|
||||
<Input
|
||||
id="task-venv"
|
||||
{...register('script.venv')}
|
||||
className="mono-input"
|
||||
placeholder="例如:.venv"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<VenvDetectorBar
|
||||
workingDir={debouncedWorkingDir}
|
||||
probe={venvProbe.data}
|
||||
pending={venvProbe.isPending}
|
||||
failed={venvProbe.isError}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="脚本文件或相对路径"
|
||||
htmlFor="task-script"
|
||||
@@ -755,6 +826,30 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
||||
return values.length ? ` ${values.join(' ')}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测结果给的是绝对路径;虚拟环境位于工作目录内时写成相对路径,
|
||||
* 任务搬家后仍然可用(与手填 `.venv` 的语义一致)。
|
||||
*/
|
||||
function venvFieldValue(workingDir: string, venvPath: string): string {
|
||||
const base = workingDir.trim().replace(/[\\/]+$/, '')
|
||||
const path = venvPath.trim()
|
||||
if (!base || !path) {
|
||||
return path
|
||||
}
|
||||
const normalizedBase = base.toLowerCase()
|
||||
const normalizedPath = path.toLowerCase()
|
||||
if (normalizedPath === normalizedBase) {
|
||||
return '.'
|
||||
}
|
||||
if (
|
||||
normalizedPath.startsWith(`${normalizedBase}\\`) ||
|
||||
normalizedPath.startsWith(`${normalizedBase}/`)
|
||||
) {
|
||||
return path.slice(base.length + 1).replace(/\//g, '\\')
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function formatInterpreterPreview(
|
||||
script: TaskFormValues['script'],
|
||||
linkedPythonPath?: string | null,
|
||||
|
||||
@@ -71,7 +71,13 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 组件库的「无底色按钮」依赖宿主这份 reset(Select 选项、Segmented、Tabs、Menu
|
||||
都只声明选中态底色)。少了它,浏览器默认的 ButtonFace 灰底和按钮边框会露出来,
|
||||
下拉选项会变成一块块灰条。 */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +68,12 @@ const ACTION_TYPE_OPTIONS = [
|
||||
]
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称 / 任务标识', width: 260 },
|
||||
{ key: 'schedule', title: '调度周期', width: 240 },
|
||||
{ key: 'type', title: '类型', width: 96 },
|
||||
{ key: 'status', title: '状态', width: 120 },
|
||||
{ key: 'timeout', title: '超时配置', width: 100 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 300 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
export function TaskDashboard() {
|
||||
@@ -89,6 +89,7 @@ export function TaskDashboard() {
|
||||
const [logTask, setLogTask] = useState<Task | null>(null)
|
||||
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [queryDraft, setQueryDraft] = useState(q)
|
||||
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
||||
|
||||
@@ -229,9 +230,19 @@ export function TaskDashboard() {
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['health'] })
|
||||
const refreshAll = async () => {
|
||||
if (refreshing) {
|
||||
return
|
||||
}
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['health'] }),
|
||||
])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tasks.map((task) => {
|
||||
@@ -244,9 +255,12 @@ export function TaskDashboard() {
|
||||
id: task.id,
|
||||
name: (
|
||||
<div className="task-name-cell">
|
||||
<Text className="task-name" strong>
|
||||
{task.name}
|
||||
</Text>
|
||||
<div className="task-name-row">
|
||||
<Text className="task-name" strong>
|
||||
{task.name}
|
||||
</Text>
|
||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||
</div>
|
||||
<Text className="task-id" size="xs" tone="muted">
|
||||
{task.id}
|
||||
</Text>
|
||||
@@ -255,9 +269,6 @@ export function TaskDashboard() {
|
||||
schedule: (
|
||||
<div className="schedule-cell">
|
||||
<code className="code-chip">{task.cron}</code>
|
||||
<Text size="xs" tone="muted">
|
||||
{task.timezone}
|
||||
</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
{task.enabled
|
||||
? nextRuns.get(task.id)
|
||||
@@ -267,8 +278,10 @@ export function TaskDashboard() {
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
type: (
|
||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||
timezone: (
|
||||
<Text className="timezone-cell" size="sm" tone="sub">
|
||||
{task.timezone}
|
||||
</Text>
|
||||
),
|
||||
status: <EnabledBadge enabled={task.enabled} />,
|
||||
timeout: (
|
||||
@@ -403,8 +416,11 @@ export function TaskDashboard() {
|
||||
清除筛选
|
||||
</TextButton>
|
||||
) : null}
|
||||
<TextButton onClick={refreshAll} disabled={tasksQuery.isFetching}>
|
||||
{tasksQuery.isFetching ? '同步中...' : '刷新'}
|
||||
<TextButton
|
||||
onClick={() => void refreshAll()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
刷新
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user