Compare commits

...
8 Commits
Author SHA1 Message Date
CNWei d7a6e17531 style(frontend): 去掉刷新提示条、隐藏弹窗滚动条并前置时区列
- 刷新按钮保持「刷新」文案,不再出现「同步中...」和顶部进度条,仅禁用防重复点击
- 任务编辑器 / 目录选择弹窗隐藏滚动条,滚轮与触控板仍可滚动
- 任务表「时区」列移到「调度周期」前面
2026-11-02 23:43:31 +08:00
CNWei c7a66c22b6 feat(frontend): 时区下拉、表格时区列与刷新反馈
- 编辑器 IANA 时区改为 Select,当前只保留 Asia/Shanghai;编辑旧任务时保留其已有值
- 任务表新增「时区」列,类型标签并入名称列,重新分配列宽
- 手动刷新改为独立状态 + 顶部进度条 + 固定按钮宽度,避免刷新时工具栏左右跳动与旧内容残留的观感
2026-11-02 23:32:03 +08:00
CNWei 608872bf06 fix(frontend): 修复已结束运行打不开日志的问题
StrictMode 的 effect 清理会取消挂载时排队的 rAF,但没有把句柄归零;已结束的 run 在挂载时就处于 complete 状态,后续 ingest 看到「还有帧在排队」便不再安排渲染,日志一直停在「等待日志输出...」。取消后把 frameRef 归零,并在 e2e 增加「运行结束后重新打开日志」的回放断言。
2026-11-02 23:19:07 +08:00
CNWei d3f0f57f6d feat(frontend): 自动回填并支持修改虚拟环境目录
- 虚拟环境目录与解释器移到工作目录正下方,虚拟环境目录优先且更宽
- 探测到虚拟环境时自动填入(工作目录内用相对路径 .venv),仍可手动修改
- 手动改过的值不再被探测结果覆盖;换工作目录只清理上一轮自动填入的值并重新探测
- 防抖期间忽略过期探测结果,避免旧环境被回填
2026-11-02 23:08:13 +08:00
CNWei b7ded69a7d fix(cron): 用中文说明替代解析器原始报错
- 后端 CronPreviewError 增加 user_message,预览与任务保存共用;解析器英文原文只写日志
- 针对「0/2」这类单数字步长给出具体改法(*/2 或 1-10/2)
- 前端预览面板用警告图标 + 中文提示展示,网络异常回退通用文案,Cron 输入同步 aria-invalid
2026-11-02 22:35:39 +08:00
CNWei b2ff255e32 style(frontend): 重排任务编辑器布局
- 未来运行时间改为全宽三列面板,次数选择器移到面板头部,去掉内部滚动条
- Cron / 超时、时区 / 任务类型两两成行,消除原右列的大片空白
- 校验改为提交时触发,避免 blur 插错误行导致下方控件位移、下拉框点击丢失
2026-11-02 22:28:31 +08:00
CNWei 369fc07b62 style(frontend): 搜索框贴左边缘对齐
工具栏去掉左侧内边距,让搜索框与页头、指标卡的左边缘对齐;右侧保留 16px 给筛选控件,窄屏恢复左右对称留白。
2026-11-02 22:00:04 +08:00
CNWei 69d721055d fix(frontend): 补齐全局按钮 reset,修复 Select 选项灰底
组件库的 Select / Segmented / Tabs / Menu 都把按钮底色交给宿主 reset;之前 index.css 只设了 cursor,Chrome 默认的 ButtonFace 灰底和按钮边框就露了出来,下拉面板看起来像一块块灰条。
2026-11-02 21:47:03 +08:00
9 changed files with 348 additions and 119 deletions
+4 -1
View File
@@ -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
View File
@@ -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 {
+6 -5
View File
@@ -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(())
+7
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -409,6 +409,10 @@ function RunLogOutput({
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
// StrictMode 会「挂载 → 清理 → 再挂载」,取消后必须把句柄归零,
// 否则下一次 scheduleRender 会误以为还有帧在排队而不再安排渲染,
// 已结束的 run(complete 在首帧就为 true)会一直停在「等待日志输出」。
frameRef.current = 0
}
}
}, [])
+176 -81
View File
@@ -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}
<section
className="cron-preview-panel"
aria-label="未来运行时间"
aria-live="polite"
>
<Input
id="task-timezone"
{...register('timezone')}
list="timezone-options"
placeholder="Asia/Shanghai"
aria-invalid={Boolean(errors.timezone)}
<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))}
/>
<datalist id="timezone-options">
{TIMEZONE_SUGGESTIONS.map((zone) => (
<option value={zone} key={zone} />
</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 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"
/>
<div className="form-grid">
</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,
+6
View File
@@ -71,7 +71,13 @@ textarea {
font: inherit;
}
/* 组件库的「无底色按钮」依赖宿主这份 reset(Select 选项、Segmented、Tabs、Menu
都只声明选中态底色)。少了它,浏览器默认的 ButtonFace 灰底和按钮边框会露出来,
下拉选项会变成一块块灰条。 */
button {
border: none;
background: none;
color: inherit;
cursor: pointer;
}
+32 -16
View File
@@ -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">
<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>