diff --git a/backend/src/api.rs b/backend/src/api.rs
index 6ff8a2c..75f1ff6 100644
--- a/backend/src/api.rs
+++ b/backend/src/api.rs
@@ -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))
}
diff --git a/backend/src/cron.rs b/backend/src/cron.rs
index 23a7a74..8635abc 100644
--- a/backend/src/cron.rs
+++ b/backend/src/cron.rs
@@ -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 {
diff --git a/backend/src/tasks/service.rs b/backend/src/tasks/service.rs
index 1346b3a..9eba333 100644
--- a/backend/src/tasks/service.rs
+++ b/backend/src/tasks/service.rs
@@ -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(())
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f2e1caf..87f1da4 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -310,7 +310,17 @@
}
.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 {
diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx
index 021431d..67a8d88 100644
--- a/frontend/src/components/TaskEditorModal.tsx
+++ b/frontend/src/components/TaskEditorModal.tsx
@@ -4,6 +4,7 @@ import {
Button,
Checkbox,
FormField,
+ Icon,
Input,
Modal,
Notice,
@@ -290,7 +291,7 @@ export function TaskEditorModal({
{...register('cron')}
className="mono-input"
placeholder="0 0 2 * * *"
- aria-invalid={Boolean(errors.cron)}
+ aria-invalid={Boolean(errors.cron) || cronPreview.isError}
/>
@@ -354,9 +355,14 @@ export function TaskEditorModal({
) : cronPreview.isError ? (
-