feat(campaign): reverse time-scale input as target runtime

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)
This commit is contained in:
sinohqb 2026-07-31 16:55:43 +08:00
parent 8bc5aa6979
commit 0e1ab75d77
2 changed files with 114 additions and 12 deletions

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space, 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' } from 'antd'
import { import {
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
@ -16,6 +16,7 @@ import {
} from '../api' } from '../api'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
import { shortDateTime } from '../utils/date' import { shortDateTime } from '../utils/date'
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
import { useResource } from '../hooks/useResource' import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling' import { usePolling } from '../hooks/usePolling'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
@ -31,6 +32,16 @@ const CAMPAIGN_STATUS: Record<string, { label: string; color: string }> = {
const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 })) 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 POLL_INTERVAL_MS = 5000
const isActiveStatus = (status: string) => status === 'planned' || status === 'running' const isActiveStatus = (status: string) => status === 'planned' || status === 'running'
@ -65,6 +76,12 @@ export default function CampaignsPage() {
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm() 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 [reportOpen, setReportOpen] = useState(false)
const [reportLoading, setReportLoading] = useState(false) const [reportLoading, setReportLoading] = useState(false)
const [report, setReport] = useState<CampaignReport | null>(null) const [report, setReport] = useState<CampaignReport | null>(null)
@ -94,7 +111,8 @@ export default function CampaignsPage() {
const openCreate = () => { const openCreate = () => {
form.setFieldsValue({ 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 }], plan: [{ scenario_id: undefined, offset_hours: 0, count: 1 }],
}) })
setCreateOpen(true) setCreateOpen(true)
@ -102,13 +120,17 @@ export default function CampaignsPage() {
const submitCreate = async () => { const submitCreate = async () => {
const values = await form.validateFields() 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) setSubmitting(true)
try { try {
await campaignsApi.create({ await campaignsApi.create({
name: values.name, name: values.name,
target_id: values.target_id, target_id: values.target_id,
window_seconds: values.window_seconds, window_seconds: windowSeconds,
time_scale: values.time_scale, time_scale,
plan: (values.plan as PlanFormEntry[]).map((e) => ({ plan: (values.plan as PlanFormEntry[]).map((e) => ({
scenario_id: e.scenario_id as string, scenario_id: e.scenario_id as string,
offset_seconds: Math.round((e.offset_hours ?? 0) * 3600), 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: 'target', render: (_: unknown, c: CampaignListItem) => targetName(c.target_id) },
{ title: '窗口', key: 'window', render: (_: unknown, c: CampaignListItem) => fmtWindow(c.window_seconds) }, { title: '窗口', key: 'window', render: (_: unknown, c: CampaignListItem) => fmtWindow(c.window_seconds) },
{ {
title: '倍速', key: 'scale', title: '加速后耗时', key: 'scale',
render: (_: unknown, c: CampaignListItem) => (c.time_scale === 1 ? '实时' : `×${c.time_scale}`), render: (_: unknown, c: CampaignListItem) => acceleratedDuration(c.window_seconds, c.time_scale),
}, },
{ {
title: '状态', key: 'status', title: '状态', key: 'status',
@ -347,16 +369,49 @@ export default function CampaignsPage() {
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="time_scale" name="realtime"
label="时间倍速" label="实时(真实墙钟)"
tooltip="1=真实墙钟(正式线);开发线可设大倍速把窗口压缩成几分钟" tooltip="开启后按真实时间推进(倍速 ×1正式线用关闭则压缩窗口加速跑完"
rules={[{ required: true }]} valuePropName="checked"
> >
<InputNumber min={0.001} step={1} style={{ width: '100%' }} /> <Switch checkedChildren="实时" unCheckedChildren="加速" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </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: 8, color: colors.textSecondary }}></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('至少一条计划条目')) } }]}> <Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
{(fields, { add, remove }, { errors }) => ( {(fields, { add, remove }, { errors }) => (
@ -424,7 +479,8 @@ export default function CampaignsPage() {
<Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col> <Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
</Row> </Row>
<div style={{ marginTop: 8, color: colors.textSecondary }}> <div style={{ marginTop: 8, color: colors.textSecondary }}>
{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`} {fmtWindow(report.window_seconds)} · {acceleratedDuration(report.window_seconds, report.time_scale)}
{' · '}{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`}
</div> </div>
<h4 style={{ marginTop: 24 }}></h4> <h4 style={{ marginTop: 24 }}></h4>

View File

@ -0,0 +1,46 @@
// Campaign time-scale helpers — the single口径 for turning a service-cycle
// window + a desired "how long it should actually take" into the backend's raw
// time_scale, and back again for display. The API contract stays in time_scale;
// users only ever think in durations.
//
// Relation: time_scale = window_seconds ÷ accelerated_seconds. So a 24h window
// that should finish in 1h runs at ×24; real wall-clock (time_scale 1) finishes
// in exactly the window length.
/** Derive the raw time_scale from a window and the desired accelerated runtime. */
export function deriveTimeScale(windowSeconds: number, targetSeconds: number): number {
if (targetSeconds <= 0) return 1
return windowSeconds / targetSeconds
}
/** "×N" display for a raw time_scale. */
export function formatScale(timeScale: number): string {
return `×${trim(timeScale)}`
}
/** Seconds the campaign actually takes once accelerated by time_scale. */
function acceleratedSeconds(windowSeconds: number, timeScale: number): number {
if (timeScale <= 0) return windowSeconds
return windowSeconds / timeScale
}
/** Human "约 X" duration, rounded to the coarsest sensible unit. */
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '—'
if (seconds < 60) return `${Math.round(seconds)}`
const mins = seconds / 60
if (mins < 60) return `${trim(mins)} 分钟`
const hours = mins / 60
if (hours < 48) return `${trim(hours)} 小时`
return `${trim(hours / 24)}`
}
/** Display string for list/detail: "约 X 完成", derived from window + time_scale. */
export function acceleratedDuration(windowSeconds: number, timeScale: number): string {
if (timeScale === 1) return '实时'
return `${formatDuration(acceleratedSeconds(windowSeconds, timeScale))}完成`
}
function trim(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}