From 0e1ab75d77e5798646c2f324f73542125c346901 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Fri, 31 Jul 2026 16:55:43 +0800 Subject: [PATCH] feat(campaign): reverse time-scale input as target runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users now pick a window length + "how long it should actually take" and the form derives time_scale (window ÷ target), showing 倍速 ×N and 加速后耗时 read-only; a 实时 switch pins real wall-clock (×1). Validation blocks a target longer than the window. List/detail display accelerated duration instead of raw ×N. POST /campaigns contract unchanged. (v0.6 ticket 07) --- frontend/web/src/pages/Campaigns.tsx | 80 ++++++++++++++++++++++---- frontend/web/src/utils/campaignTime.ts | 46 +++++++++++++++ 2 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 frontend/web/src/utils/campaignTime.ts diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index bfd9730..02e1d02 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space, - Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, + Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, Switch, } from 'antd' import { PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, @@ -16,6 +16,7 @@ import { } from '../api' import { passRateColor } from '../utils/colors' import { shortDateTime } from '../utils/date' +import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime' import { useResource } from '../hooks/useResource' import { usePolling } from '../hooks/usePolling' import { useTabStore } from '../stores/tabStore' @@ -31,6 +32,16 @@ const CAMPAIGN_STATUS: Record = { const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 })) +const UNIT_OPTIONS = [ + { label: '分钟', value: 60 }, + { label: '小时', value: 3600 }, +] + +/** Single口径 for turning the reverse inputs into the backend's raw time_scale. */ +function scaleFor(realtime: boolean, windowSeconds: number, targetValue: number, targetUnit: number): number { + return realtime ? 1 : deriveTimeScale(windowSeconds, targetValue * targetUnit) +} + const POLL_INTERVAL_MS = 5000 const isActiveStatus = (status: string) => status === 'planned' || status === 'running' @@ -65,6 +76,12 @@ export default function CampaignsPage() { const [submitting, setSubmitting] = useState(false) const [form] = Form.useForm() + const wWindow = (Form.useWatch('window_seconds', form) as number | undefined) ?? 0 + const wRealtime = Form.useWatch('realtime', form) as boolean | undefined + const wTargetValue = (Form.useWatch('target_value', form) as number | undefined) ?? 0 + const wTargetUnit = (Form.useWatch('target_unit', form) as number | undefined) ?? 60 + const derivedScale = scaleFor(!!wRealtime, wWindow, wTargetValue, wTargetUnit) + const [reportOpen, setReportOpen] = useState(false) const [reportLoading, setReportLoading] = useState(false) const [report, setReport] = useState(null) @@ -94,7 +111,8 @@ export default function CampaignsPage() { const openCreate = () => { form.setFieldsValue({ - name: '', target_id: undefined, window_seconds: 24 * 3600, time_scale: 1, + name: '', target_id: undefined, window_seconds: 24 * 3600, + realtime: false, target_value: 60, target_unit: 60, plan: [{ scenario_id: undefined, offset_hours: 0, count: 1 }], }) setCreateOpen(true) @@ -102,13 +120,17 @@ export default function CampaignsPage() { const submitCreate = async () => { const values = await form.validateFields() + const windowSeconds = values.window_seconds as number + const time_scale = scaleFor( + !!values.realtime, windowSeconds, values.target_value ?? 0, values.target_unit ?? 60, + ) setSubmitting(true) try { await campaignsApi.create({ name: values.name, target_id: values.target_id, - window_seconds: values.window_seconds, - time_scale: values.time_scale, + window_seconds: windowSeconds, + time_scale, plan: (values.plan as PlanFormEntry[]).map((e) => ({ scenario_id: e.scenario_id as string, offset_seconds: Math.round((e.offset_hours ?? 0) * 3600), @@ -168,8 +190,8 @@ export default function CampaignsPage() { { title: '评测对象', key: 'target', render: (_: unknown, c: CampaignListItem) => targetName(c.target_id) }, { title: '窗口', key: 'window', render: (_: unknown, c: CampaignListItem) => fmtWindow(c.window_seconds) }, { - title: '倍速', key: 'scale', - render: (_: unknown, c: CampaignListItem) => (c.time_scale === 1 ? '实时' : `×${c.time_scale}`), + title: '加速后耗时', key: 'scale', + render: (_: unknown, c: CampaignListItem) => acceleratedDuration(c.window_seconds, c.time_scale), }, { title: '状态', key: 'status', @@ -347,16 +369,49 @@ export default function CampaignsPage() { - + + + + { + 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)') + }, + }]} + > + + + +