feat(frontend): 时区下拉、表格时区列与刷新反馈
- 编辑器 IANA 时区改为 Select,当前只保留 Asia/Shanghai;编辑旧任务时保留其已有值 - 任务表新增「时区」列,类型标签并入名称列,重新分配列宽 - 手动刷新改为独立状态 + 顶部进度条 + 固定按钮宽度,避免刷新时工具栏左右跳动与旧内容残留的观感
This commit is contained in:
+53
-1
@@ -62,9 +62,44 @@
|
|||||||
/* ---------- 任务列表 ---------- */
|
/* ---------- 任务列表 ---------- */
|
||||||
|
|
||||||
.task-card {
|
.task-card {
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手动刷新时的顶部进度条:不改变表格布局,也避免旧数据看起来像「残影」 */
|
||||||
|
.refresh-bar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--su-color-primary-soft);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-bar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 40%;
|
||||||
|
background-color: var(--su-color-primary);
|
||||||
|
animation: refresh-slide 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes refresh-slide {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(250%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 表格嵌在卡片里:去掉自身描边与圆角,避免「卡片套卡片」 */
|
/* 表格嵌在卡片里:去掉自身描边与圆角,避免「卡片套卡片」 */
|
||||||
.task-table {
|
.task-table {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -99,6 +134,12 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「刷新」和「同步中...」字数不同,固定宽度避免刷新时工具栏左右跳动 */
|
||||||
|
.refresh-button {
|
||||||
|
min-width: 68px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.task-name-cell {
|
.task-name-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -106,9 +147,16 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-name-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-name {
|
.task-name {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 240px;
|
max-width: 170px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -119,6 +167,10 @@
|
|||||||
font-family: var(--su-font-mono);
|
font-family: var(--su-font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timezone-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-cell {
|
.schedule-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -60,13 +60,7 @@ const HTTP_METHOD_OPTIONS = [
|
|||||||
{ value: 'POST', label: 'POST' },
|
{ value: 'POST', label: 'POST' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TIMEZONE_SUGGESTIONS = [
|
const TIMEZONE_OPTIONS = ['Asia/Shanghai']
|
||||||
'Asia/Shanghai',
|
|
||||||
'Asia/Hong_Kong',
|
|
||||||
'UTC',
|
|
||||||
'Europe/London',
|
|
||||||
'America/New_York',
|
|
||||||
]
|
|
||||||
|
|
||||||
const CRON_PREVIEW_COUNTS = [3, 5, 8, 10]
|
const CRON_PREVIEW_COUNTS = [3, 5, 8, 10]
|
||||||
|
|
||||||
@@ -112,6 +106,11 @@ export function TaskEditorModal({
|
|||||||
const cronValue = watch('cron')
|
const cronValue = watch('cron')
|
||||||
const timezoneValue = watch('timezone')
|
const timezoneValue = watch('timezone')
|
||||||
const scriptPreview = watch('script')
|
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 debouncedCron = useDebouncedValue(cronValue, 300)
|
||||||
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
||||||
const debouncedWorkingDir = useDebouncedValue(
|
const debouncedWorkingDir = useDebouncedValue(
|
||||||
@@ -425,23 +424,19 @@ export function TaskEditorModal({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<FormField
|
<FormField label="IANA 时区" error={errors.timezone?.message}>
|
||||||
label="IANA 时区"
|
<Controller
|
||||||
htmlFor="task-timezone"
|
control={control}
|
||||||
error={errors.timezone?.message}
|
name="timezone"
|
||||||
>
|
render={({ field }) => (
|
||||||
<Input
|
<Select
|
||||||
id="task-timezone"
|
className="form-select"
|
||||||
{...register('timezone')}
|
options={timezoneOptions}
|
||||||
list="timezone-options"
|
value={field.value}
|
||||||
placeholder="Asia/Shanghai"
|
onChange={field.onChange}
|
||||||
aria-invalid={Boolean(errors.timezone)}
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<datalist id="timezone-options">
|
|
||||||
{TIMEZONE_SUGGESTIONS.map((zone) => (
|
|
||||||
<option value={zone} key={zone} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="任务类型 (Action Type)">
|
<FormField label="任务类型 (Action Type)">
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ const ACTION_TYPE_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const TABLE_COLUMNS: TableColumn[] = [
|
const TABLE_COLUMNS: TableColumn[] = [
|
||||||
{ key: 'name', title: '名称 / 任务标识', width: 260 },
|
{ key: 'name', title: '名称 / 任务标识', width: 250 },
|
||||||
{ key: 'schedule', title: '调度周期', width: 240 },
|
{ key: 'schedule', title: '调度周期', width: 210 },
|
||||||
{ key: 'type', title: '类型', width: 96 },
|
{ key: 'timezone', title: '时区', width: 120 },
|
||||||
{ key: 'status', title: '状态', width: 120 },
|
{ key: 'status', title: '状态', width: 110 },
|
||||||
{ key: 'timeout', title: '超时配置', width: 100 },
|
{ key: 'timeout', title: '超时', width: 90 },
|
||||||
{ key: 'actions', title: '操作', align: 'right', width: 300 },
|
{ key: 'actions', title: '操作', align: 'right', width: 260 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function TaskDashboard() {
|
export function TaskDashboard() {
|
||||||
@@ -89,6 +89,7 @@ export function TaskDashboard() {
|
|||||||
const [logTask, setLogTask] = useState<Task | null>(null)
|
const [logTask, setLogTask] = useState<Task | null>(null)
|
||||||
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [queryDraft, setQueryDraft] = useState(q)
|
const [queryDraft, setQueryDraft] = useState(q)
|
||||||
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
||||||
|
|
||||||
@@ -229,9 +230,19 @@ export function TaskDashboard() {
|
|||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshAll = () => {
|
const refreshAll = async () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
if (refreshing) {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['health'] })
|
return
|
||||||
|
}
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health'] }),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = tasks.map((task) => {
|
const rows = tasks.map((task) => {
|
||||||
@@ -244,9 +255,12 @@ export function TaskDashboard() {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
name: (
|
name: (
|
||||||
<div className="task-name-cell">
|
<div className="task-name-cell">
|
||||||
<Text className="task-name" strong>
|
<div className="task-name-row">
|
||||||
{task.name}
|
<Text className="task-name" strong>
|
||||||
</Text>
|
{task.name}
|
||||||
|
</Text>
|
||||||
|
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||||
|
</div>
|
||||||
<Text className="task-id" size="xs" tone="muted">
|
<Text className="task-id" size="xs" tone="muted">
|
||||||
{task.id}
|
{task.id}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -255,9 +269,6 @@ export function TaskDashboard() {
|
|||||||
schedule: (
|
schedule: (
|
||||||
<div className="schedule-cell">
|
<div className="schedule-cell">
|
||||||
<code className="code-chip">{task.cron}</code>
|
<code className="code-chip">{task.cron}</code>
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
{task.timezone}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
{task.enabled
|
{task.enabled
|
||||||
? nextRuns.get(task.id)
|
? nextRuns.get(task.id)
|
||||||
@@ -267,8 +278,10 @@ export function TaskDashboard() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
type: (
|
timezone: (
|
||||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
<Text className="timezone-cell" size="sm" tone="sub">
|
||||||
|
{task.timezone}
|
||||||
|
</Text>
|
||||||
),
|
),
|
||||||
status: <EnabledBadge enabled={task.enabled} />,
|
status: <EnabledBadge enabled={task.enabled} />,
|
||||||
timeout: (
|
timeout: (
|
||||||
@@ -365,6 +378,9 @@ export function TaskDashboard() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card padding="none" className="task-card">
|
<Card padding="none" className="task-card">
|
||||||
|
{refreshing ? (
|
||||||
|
<div className="refresh-bar" role="progressbar" aria-label="正在刷新" />
|
||||||
|
) : null}
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<SearchField
|
<SearchField
|
||||||
className="toolbar-search"
|
className="toolbar-search"
|
||||||
@@ -403,8 +419,12 @@ export function TaskDashboard() {
|
|||||||
清除筛选
|
清除筛选
|
||||||
</TextButton>
|
</TextButton>
|
||||||
) : null}
|
) : null}
|
||||||
<TextButton onClick={refreshAll} disabled={tasksQuery.isFetching}>
|
<TextButton
|
||||||
{tasksQuery.isFetching ? '同步中...' : '刷新'}
|
className="refresh-button"
|
||||||
|
onClick={() => void refreshAll()}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
{refreshing ? '同步中...' : '刷新'}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user