feat(campaign): drawer create form and scenario-lane run timeline
Create surface moves from a 640px modal to a 920px two-column drawer: basic info + time/speed on the left, plan preview and a grid-aligned plan editor (headers, searchable scenario selects, scrollable entries) on the right, so many-entry plans stay editable. The expanded-row process timeline switches from a single crowded axis to per-scenario lanes with a now-line for active campaigns.
This commit is contained in:
parent
67a574da18
commit
3aac5e8068
143
frontend/web/src/components/CampaignRunTimeline.tsx
Normal file
143
frontend/web/src/components/CampaignRunTimeline.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { Tooltip } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { CampaignTimelineEntry } from '../api'
|
||||
import { colors, fontSizes, statusColors, statusLabels } from '../tokens'
|
||||
import { fmtTick } from './WindowTimeline'
|
||||
import { shortDateTime } from '../utils/date'
|
||||
|
||||
// Expanded-row view of a campaign: one lane per scenario under a shared window
|
||||
// axis, each child Run a status-coloured node. For still-active campaigns a
|
||||
// dashed line marks how far the window has progressed (nowOffsetSeconds).
|
||||
|
||||
interface CampaignRunTimelineProps {
|
||||
windowSeconds: number
|
||||
entries: CampaignTimelineEntry[]
|
||||
nowOffsetSeconds?: number | null
|
||||
}
|
||||
|
||||
const TICK_COUNT = 6
|
||||
const LANE_HEIGHT = 30
|
||||
const GUTTER = 140
|
||||
const TRACK_RIGHT = 12
|
||||
|
||||
export default function CampaignRunTimeline({
|
||||
windowSeconds, entries, nowOffsetSeconds,
|
||||
}: CampaignRunTimelineProps) {
|
||||
const navigate = useNavigate()
|
||||
const window = windowSeconds > 0 ? windowSeconds : 1
|
||||
const clampPct = (offset: number) => Math.min(1, Math.max(0, offset / window)) * 100
|
||||
|
||||
// Entries arrive sorted by offset — lanes appear in chronological first-use order.
|
||||
const lanes: { key: string; name: string; items: CampaignTimelineEntry[] }[] = []
|
||||
for (const e of entries) {
|
||||
let lane = lanes.find((l) => l.key === e.scenario_id)
|
||||
if (!lane) {
|
||||
lane = { key: e.scenario_id, name: e.scenario_name, items: [] }
|
||||
lanes.push(lane)
|
||||
}
|
||||
lane.items.push(e)
|
||||
}
|
||||
|
||||
const presentStatuses = Array.from(new Set(entries.map((e) => e.status))).sort()
|
||||
const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT)
|
||||
const nowPct = nowOffsetSeconds == null ? null : clampPct(nowOffsetSeconds)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: 'relative', height: 16, marginLeft: GUTTER, marginRight: TRACK_RIGHT }}>
|
||||
{ticks.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
style={{
|
||||
position: 'absolute', left: `${t * 100}%`, transform: 'translateX(-50%)',
|
||||
fontSize: fontSizes.meta, color: colors.textMuted, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{fmtTick(t * window)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
{nowPct != null && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, bottom: 0,
|
||||
left: GUTTER, right: TRACK_RIGHT, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', left: `${nowPct}%`, top: -2, bottom: -2,
|
||||
width: 0, borderLeft: `1px dashed ${colors.primary}`,
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{lanes.map((lane) => (
|
||||
<div key={lane.key} style={{ display: 'flex', alignItems: 'center', height: LANE_HEIGHT }}>
|
||||
<div
|
||||
title={lane.name}
|
||||
style={{
|
||||
width: GUTTER, flexShrink: 0, paddingRight: 10,
|
||||
fontSize: fontSizes.body, color: colors.text,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{lane.name}
|
||||
</div>
|
||||
<div style={{ position: 'relative', flex: 1, height: '100%', marginRight: TRACK_RIGHT }}>
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, right: 0, top: '50%',
|
||||
height: 2, marginTop: -1, background: colors.border,
|
||||
}} />
|
||||
{lane.items.map((e) => (
|
||||
<Tooltip
|
||||
key={e.run_id}
|
||||
title={
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div>{e.scenario_name} · {statusLabels[e.status] ?? e.status}</div>
|
||||
<div>通过率:{e.pass_rate == null ? '—' : `${(e.pass_rate * 100).toFixed(1)}%`}</div>
|
||||
<div>时延:{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}</div>
|
||||
<div>开始:{shortDateTime(e.started_at)}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span
|
||||
onClick={() => navigate(`/reports?run=${e.run_id}`)}
|
||||
style={{
|
||||
position: 'absolute', left: `${clampPct(e.offset_seconds)}%`, top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
background: statusColors[e.status] ?? colors.textMuted,
|
||||
border: '2px solid #fff', boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 6,
|
||||
fontSize: fontSizes.meta, color: colors.textSecondary,
|
||||
}}>
|
||||
{presentStatuses.map((s) => (
|
||||
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: statusColors[s] ?? colors.textMuted,
|
||||
}} />
|
||||
{statusLabels[s] ?? s}
|
||||
</span>
|
||||
))}
|
||||
{nowPct != null && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 0, height: 10, borderLeft: `1px dashed ${colors.primary}` }} />
|
||||
当前进度
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginLeft: 'auto', color: colors.textMuted }}>点击节点查看单次报告</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -35,7 +35,7 @@ const PALETTE = [
|
||||
|
||||
const TICK_COUNT = 6
|
||||
|
||||
function fmtTick(seconds: number): string {
|
||||
export function fmtTick(seconds: number): string {
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
|
||||
const h = seconds / 3600
|
||||
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
|
||||
Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip,
|
||||
Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, Switch,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
|
||||
FileMarkdownOutlined, MinusCircleOutlined,
|
||||
FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Line } from '@ant-design/charts'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
@ -16,9 +16,10 @@ import {
|
||||
type CampaignTimelineEntry,
|
||||
} from '../api'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
import { shortDateTime } from '../utils/date'
|
||||
import { shortDateTime, toDate } from '../utils/date'
|
||||
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
|
||||
import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline'
|
||||
import CampaignRunTimeline from '../components/CampaignRunTimeline'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
@ -48,6 +49,17 @@ const POLL_INTERVAL_MS = 5000
|
||||
|
||||
const isActiveStatus = (status: string) => status === 'planned' || status === 'running'
|
||||
|
||||
/** How far an active campaign's window has progressed, in window seconds. */
|
||||
function nowOffsetFor(c: CampaignListItem): number | null {
|
||||
if (!isActiveStatus(c.status) || !c.started_at) return null
|
||||
const elapsed = (Date.now() - toDate(c.started_at).getTime()) / 1000
|
||||
return Math.min(Math.max(elapsed * c.time_scale, 0), c.window_seconds)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: ReactNode }) {
|
||||
return <div style={{ fontWeight: 600, fontSize: 13, margin: '4px 0 12px' }}>{children}</div>
|
||||
}
|
||||
|
||||
function fmtWindow(seconds: number): string {
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||
@ -366,26 +378,11 @@ export default function CampaignsPage() {
|
||||
const entries = timelines[c.id]
|
||||
if (entries === undefined) return <Spin />
|
||||
if (entries.length === 0) return <Empty description="暂无子运行" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
const markers: TimelineMarker[] = entries.map((e) => ({
|
||||
key: e.run_id,
|
||||
offsetSeconds: e.offset_seconds,
|
||||
colorKey: e.status,
|
||||
tooltip: (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div>{e.scenario_name}</div>
|
||||
<div>通过率:{fmtPct(e.pass_rate)}</div>
|
||||
<div>时延:{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}</div>
|
||||
</div>
|
||||
),
|
||||
onClick: () => navigate(`/reports?run=${e.run_id}`),
|
||||
}))
|
||||
return (
|
||||
<WindowTimeline
|
||||
<CampaignRunTimeline
|
||||
windowSeconds={c.window_seconds}
|
||||
markers={markers}
|
||||
colorMap={statusColors}
|
||||
colorLabels={statusLabels}
|
||||
showLegend
|
||||
entries={entries}
|
||||
nowOffsetSeconds={nowOffsetFor(c)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -420,32 +417,46 @@ export default function CampaignsPage() {
|
||||
</div>
|
||||
|
||||
{/* 创建活动 */}
|
||||
<Modal
|
||||
<Drawer
|
||||
title="新建评估活动"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={submitCreate}
|
||||
confirmLoading={submitting}
|
||||
width={640}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
width={920}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={submitting} onClick={submitCreate}>
|
||||
创建并开始调度
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="活动名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:数字员工 24h 服务周期" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
|
||||
<Select
|
||||
placeholder="选择评测对象"
|
||||
options={targets.map((t) => ({ label: t.name, value: t.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="window_seconds" label="窗口长度" rules={[{ required: true }]}>
|
||||
<Row gutter={32}>
|
||||
<Col span={9}>
|
||||
<SectionTitle>基本信息</SectionTitle>
|
||||
<Form.Item name="name" label="活动名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:数字员工 24h 服务周期" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
|
||||
<Select
|
||||
placeholder="选择评测对象"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={targets.map((t) => ({ label: t.name, value: t.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<SectionTitle>时间与速度</SectionTitle>
|
||||
<Form.Item
|
||||
name="window_seconds"
|
||||
label="窗口长度"
|
||||
tooltip="服务对象的一个完整服务周期(如早 8 点到次日早 8 点 = 24 小时),所有计划条目都落在窗口内"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select options={WINDOW_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="realtime"
|
||||
label="实时(真实墙钟)"
|
||||
@ -454,98 +465,125 @@ export default function CampaignsPage() {
|
||||
>
|
||||
<Switch checkedChildren="实时" unCheckedChildren="加速" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="希望加速后多久跑完"
|
||||
tooltip="按“耗时”思考:例如 24 小时窗口希望 1 小时内跑完 → 自动换算为 ×24"
|
||||
required={!wRealtime}
|
||||
>
|
||||
<Space align="baseline">
|
||||
<Form.Item
|
||||
name="target_value"
|
||||
noStyle
|
||||
rules={[{
|
||||
validator: async (_, v: number | undefined) => {
|
||||
if (form.getFieldValue('realtime')) return
|
||||
if (!v || v <= 0) throw new Error('请输入目标耗时')
|
||||
const unit = (form.getFieldValue('target_unit') as number | undefined) ?? 60
|
||||
const win = (form.getFieldValue('window_seconds') as number | undefined) ?? 0
|
||||
if (v * unit > win) throw new Error('目标耗时不能超过窗口长度(否则倍速 < 1)')
|
||||
},
|
||||
}]}
|
||||
>
|
||||
<InputNumber min={0.1} step={1} disabled={wRealtime} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_unit" noStyle>
|
||||
<Select options={UNIT_OPTIONS} disabled={wRealtime} style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<div style={{
|
||||
background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8,
|
||||
padding: '8px 12px', fontSize: 12, color: colors.text,
|
||||
}}>
|
||||
{wRealtime
|
||||
? `实时模式:按真实墙钟推进,计划贯穿整个 ${fmtWindow(wWindow)} 窗口`
|
||||
: (
|
||||
<>
|
||||
窗口 {fmtWindow(wWindow)} · 倍速 <b>{formatScale(derivedScale)}</b>
|
||||
{' · '}{acceleratedDuration(wWindow, derivedScale)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={15}>
|
||||
<SectionTitle>计划预览</SectionTitle>
|
||||
<div style={{
|
||||
border: `1px solid ${colors.border}`, borderRadius: 8,
|
||||
padding: '12px 8px', marginBottom: 16,
|
||||
}}>
|
||||
{planMarkers.length > 0 ? (
|
||||
<WindowTimeline
|
||||
windowSeconds={wWindow}
|
||||
markers={planMarkers}
|
||||
colorLabels={scenarioNames}
|
||||
showLegend
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}>
|
||||
选择场景并设置开始时间后,将在此按时间轴预览计划
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionTitle>计划条目</SectionTitle>
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: colors.textSecondary }}>
|
||||
在窗口的哪些时间点跑哪个场景、跑几次
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, fontSize: 12, color: colors.textSecondary }}>
|
||||
<span style={{ flex: 1 }}>场景</span>
|
||||
<span style={{ width: 140 }}>
|
||||
开始时间{' '}
|
||||
<Tooltip title="该场景在窗口开始后第几个小时启动">
|
||||
<QuestionCircleOutlined style={{ color: colors.textMuted }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span style={{ width: 104 }}>执行次数</span>
|
||||
<span style={{ width: 16 }} />
|
||||
</div>
|
||||
<Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<Form.Item
|
||||
name={[field.name, 'scenario_id']}
|
||||
rules={[{ required: true, message: '选择场景' }]}
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择场景"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={scenarios.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'offset_hours']} style={{ width: 140, marginBottom: 0 }}>
|
||||
<InputNumber min={0} step={0.5} addonAfter="小时后" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'count']} style={{ width: 104, marginBottom: 0 }}>
|
||||
<InputNumber min={1} addonAfter="次" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div style={{ width: 16, lineHeight: '32px' }}>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: colors.textMuted }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button type="dashed" onClick={() => add({ scenario_id: undefined, offset_hours: 0, count: 1 })} block icon={<PlusOutlined />}>
|
||||
添加计划条目
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="希望加速后多久跑完"
|
||||
tooltip="按“耗时”思考:例如 24 小时窗口希望 1 小时内跑完 → 自动换算为 ×24"
|
||||
required={!wRealtime}
|
||||
>
|
||||
<Space align="baseline">
|
||||
<Form.Item
|
||||
name="target_value"
|
||||
noStyle
|
||||
rules={[{
|
||||
validator: async (_, v: number | undefined) => {
|
||||
if (form.getFieldValue('realtime')) return
|
||||
if (!v || v <= 0) throw new Error('请输入目标耗时')
|
||||
const unit = (form.getFieldValue('target_unit') as number | undefined) ?? 60
|
||||
const win = (form.getFieldValue('window_seconds') as number | undefined) ?? 0
|
||||
if (v * unit > win) throw new Error('目标耗时不能超过窗口长度(否则倍速 < 1)')
|
||||
},
|
||||
}]}
|
||||
>
|
||||
<InputNumber min={0.1} step={1} disabled={wRealtime} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_unit" noStyle>
|
||||
<Select options={UNIT_OPTIONS} disabled={wRealtime} style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginBottom: 16, color: colors.textSecondary }}>
|
||||
倍速 <b>{wRealtime ? '×1(实时)' : formatScale(derivedScale)}</b>
|
||||
{' · '}
|
||||
{acceleratedDuration(wWindow, derivedScale)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 4, color: colors.textSecondary }}>计划预览</div>
|
||||
<div style={{
|
||||
border: `1px solid ${colors.border}`, borderRadius: 8,
|
||||
padding: '12px 8px', marginBottom: 12,
|
||||
}}>
|
||||
{planMarkers.length > 0 ? (
|
||||
<WindowTimeline
|
||||
windowSeconds={wWindow}
|
||||
markers={planMarkers}
|
||||
colorLabels={scenarioNames}
|
||||
showLegend
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}>
|
||||
选择场景并设置偏移后,将在此按时间轴预览计划
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8, color: colors.textSecondary }}>活动计划(在窗口的哪些时段跑哪个场景、跑几次)</div>
|
||||
<Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item
|
||||
name={[field.name, 'scenario_id']}
|
||||
rules={[{ required: true, message: '选择场景' }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="场景"
|
||||
style={{ width: 220 }}
|
||||
options={scenarios.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'offset_hours']} style={{ marginBottom: 0 }}>
|
||||
<InputNumber min={0} step={0.5} addonAfter="h偏移" style={{ width: 130 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'count']} style={{ marginBottom: 0 }}>
|
||||
<InputNumber min={1} addonAfter="次" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: colors.textMuted }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ scenario_id: undefined, offset_hours: 0, count: 1 })} block icon={<PlusOutlined />}>
|
||||
添加计划条目
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
|
||||
{/* 周期报告 */}
|
||||
<Drawer
|
||||
|
||||
Loading…
Reference in New Issue
Block a user