feat(frontend): 时区下拉、表格时区列与刷新反馈

- 编辑器 IANA 时区改为 Select,当前只保留 Asia/Shanghai;编辑旧任务时保留其已有值
- 任务表新增「时区」列,类型标签并入名称列,重新分配列宽
- 手动刷新改为独立状态 + 顶部进度条 + 固定按钮宽度,避免刷新时工具栏左右跳动与旧内容残留的观感
This commit is contained in:
2026-11-02 23:32:03 +08:00
parent 608872bf06
commit c7a66c22b6
3 changed files with 110 additions and 43 deletions
+53 -1
View File
@@ -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;
+18 -23
View File
@@ -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)">
+39 -19
View File
@@ -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>