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:
parent
8bc5aa6979
commit
0e1ab75d77
@ -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<string, { label: string; color: string }> = {
|
||||
|
||||
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<CampaignReport | null>(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() {
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="time_scale"
|
||||
label="时间倍速"
|
||||
tooltip="1=真实墙钟(正式线);开发线可设大倍速把窗口压缩成几分钟"
|
||||
rules={[{ required: true }]}
|
||||
name="realtime"
|
||||
label="实时(真实墙钟)"
|
||||
tooltip="开启后按真实时间推进(倍速 ×1,正式线用);关闭则压缩窗口,加速跑完"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<InputNumber min={0.001} step={1} style={{ width: '100%' }} />
|
||||
<Switch checkedChildren="实时" unCheckedChildren="加速" />
|
||||
</Form.Item>
|
||||
</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: 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 }) => (
|
||||
@ -424,7 +479,8 @@ export default function CampaignsPage() {
|
||||
<Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
|
||||
</Row>
|
||||
<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>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>时间趋势(通过率随窗口时段)</h4>
|
||||
|
||||
46
frontend/web/src/utils/campaignTime.ts
Normal file
46
frontend/web/src/utils/campaignTime.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user