[feat]:[FL-214][fl-analysis 防雷计算页面UI交互重构]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 15:23:20 +08:00
parent 57fbdbf25a
commit 0f62776cbd
12 changed files with 2174 additions and 1534 deletions
+1
View File
@@ -12,6 +12,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.turbo/
graphify-out/
# Python
.venv/
+20
View File
@@ -469,3 +469,23 @@
- 风险与关注点:
- 任务监控数据仍来自既有 Flower 批量接口,本次只改变移动端前端渲染批次和滚动加载交互,不改变接口字段、权限或任务筛选规则。
# Work Log - 防雷分析页面 UI/交互重构(FL-214)
- 背景:
- `/admin/fl-analysis` 页面原先将创建表单、任务选择、详情、结果表和 4 类弹窗全部集中在单个 `page.tsx` 内,初始页面负载过重。
- 本次处理:
- 新增 `web/src/components/fl-analysis/` 组件目录,拆出创建表单、任务卡片列表、任务详情面板、结果表、详情弹窗、措施推荐/复算/报告弹窗和共享类型/格式化函数。
- 创建表单改为默认收起,由“新建任务”按钮打开 Drawer;创建成功后关闭 Drawer 并选中新任务。
- 任务选择从 Select 改为卡片网格,展示状态、任务类型、线路、创建/完成时间和杆塔统计。
- 任务详情操作按钮按“执行 / 下游任务 / 导出下载”分组展示。
- 验证:
- 基线:`npm --workspace web exec eslint src/app/admin/fl-analysis/page.tsx --max-warnings=0` 通过。
- 基线:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
- 修改后:`npm --workspace web exec eslint src/app/admin/fl-analysis/page.tsx src/components/fl-analysis --max-warnings=0` 通过。
- 修改后:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
- 风险与关注点:
- 改动仅影响 `/admin/fl-analysis` 前端视图组织与交互入口,不改变后端接口、请求/响应字段、权限判断或任务创建/启动语义。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
"use client";
import { Alert, Button, Form, Input, InputNumber, Select, Space, Tag } from "antd";
import type { FormInstance } from "antd";
import { readLinePreparation } from "@/lib/line-preparation";
import type {
AtpEngineStatusResponse,
AtpModelSummary,
LineSummary,
} from "@/types/auth";
import {
CREATE_JOB_DEFAULTS,
formatDateTime,
formatExternalAdapter,
formatJobType,
preparationColor,
readObject,
readOptionalNumber,
readOptionalString,
type CreateJobFormValues,
} from "./types";
type AdapterOption = {
value: "placeholder" | "atp" | "wine";
label: string;
disabled: boolean;
};
type CreateJobFormProps = {
form: FormInstance<CreateJobFormValues>;
lines: LineSummary[];
linesLoading: boolean;
selectedLine: LineSummary | null;
selectedJobType: CreateJobFormValues["job_type"];
selectedExternalAdapter: CreateJobFormValues["external_adapter"];
adapterOptions: AdapterOption[];
atpModels: AtpModelSummary[];
atpModelsLoading: boolean;
atpModelsError: unknown;
selectedAtpModel: AtpModelSummary | null;
engineQueryData: AtpEngineStatusResponse | undefined;
workflowExecutionMessage: string;
submitting: boolean;
onSubmit: (values: CreateJobFormValues) => void;
};
export function CreateJobForm({
form,
lines,
linesLoading,
selectedLine,
selectedJobType,
selectedExternalAdapter,
adapterOptions,
atpModels,
atpModelsLoading,
atpModelsError,
selectedAtpModel,
engineQueryData,
workflowExecutionMessage,
submitting,
onSubmit,
}: CreateJobFormProps) {
const selectedLinePreparation = readLinePreparation(selectedLine);
const externalAdapterActive = selectedExternalAdapter === "atp" || selectedExternalAdapter === "wine";
return (
<Form<CreateJobFormValues>
form={form}
layout="vertical"
initialValues={CREATE_JOB_DEFAULTS}
onFinish={onSubmit}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<Form.Item
name="line_id"
label="线路"
rules={[{ required: true, message: "请选择线路" }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择线路"
loading={linesLoading}
options={lines.map((item) => ({
value: item.id,
label: `${item.name || item.code} / ${item.code}`,
}))}
/>
</Form.Item>
<Form.Item name="job_type" label="任务类型" rules={[{ required: true, message: "请选择任务类型" }]}>
<Select
options={[
{ value: "normal", label: "普通计算" },
{ value: "tongtiao", label: "同跳计算" },
{ value: "risk", label: "风险评估" },
]}
/>
</Form.Item>
{selectedJobType === "normal" || selectedJobType === "tongtiao" ? (
<Form.Item
name="external_adapter"
label="执行适配器"
rules={[{ required: true, message: "请选择执行适配器" }]}
>
<Select options={adapterOptions.map((item) => ({ ...item }))} />
</Form.Item>
) : null}
<Form.Item name="job_name" label="任务名">
<Input
placeholder={selectedLine
? `${selectedLine.name || selectedLine.code}-${formatJobType(selectedJobType)}`
: `${formatJobType(selectedJobType)}任务`}
/>
</Form.Item>
<Form.Item label=" ">
<Button
type="primary"
htmlType="submit"
loading={submitting}
disabled={!selectedLine || !selectedLinePreparation.all_ready}
className="w-full"
>
{formatJobType(selectedJobType)}
</Button>
</Form.Item>
</div>
{selectedLine ? (
<Alert
type={selectedLinePreparation.all_ready ? "success" : "warning"}
showIcon
message={selectedLinePreparation.all_ready ? "参数准备已完成" : `当前线路缺少:${selectedLinePreparation.missing_items.join("、")}`}
description={
<Space size={[8, 8]} wrap>
{[
selectedLinePreparation.lightning_current,
selectedLinePreparation.lightning_density,
selectedLinePreparation.ground_slope,
].map((item) => {
const source = readObject(item.source);
const preparedAt = readOptionalString(source, "prepared_at");
const currentA = readOptionalNumber(readObject(item.values), "current_a");
const currentB = readOptionalNumber(readObject(item.values), "current_b");
const suffix = item.key === "lightning_current" && currentA !== null && currentB !== null
? ` (${currentA.toFixed(3)} / ${currentB.toFixed(3)})`
: "";
return (
<Tag key={item.key} color={preparationColor(item.ready)}>
{`${item.label}${suffix} ${item.tower_ready_count}/${item.tower_total_count}${preparedAt ? ` @ ${formatDateTime(preparedAt)}` : ""}`}
</Tag>
);
})}
</Space>
}
/>
) : null}
{selectedJobType === "normal" || selectedJobType === "tongtiao" ? (
<>
<Alert
type={externalAdapterActive && engineQueryData?.available === false ? "warning" : "info"}
showIcon
message={workflowExecutionMessage}
/>
{atpModelsError && externalAdapterActive ? (
<Alert
type="error"
showIcon
message={atpModelsError instanceof Error ? atpModelsError.message : "ATP 模型列表加载失败"}
/>
) : null}
{externalAdapterActive ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<Form.Item
name="atp_model_id"
label="ATP模型"
rules={[{ required: true, message: "请选择 ATP 模型" }]}
>
<Select
showSearch
optionFilterProp="label"
loading={atpModelsLoading}
placeholder="选择 ATP 模型"
options={atpModels.map((item) => ({
value: item.id,
label: `${item.name} / ${item.code}`,
}))}
/>
</Form.Item>
<Alert
type="info"
showIcon
message={`执行模式:${engineQueryData ? formatExternalAdapter(engineQueryData.mode === "wine" ? "wine" : "atp") : "-"}`}
description={selectedAtpModel ? `当前模型:${selectedAtpModel.name} / ${selectedAtpModel.code}。执行时默认使用该模型的当前模板。` : "从 ATP 模型管理中选择可用模板。"}
className="md:col-span-1 xl:col-span-2"
/>
</div>
) : null}
<div className="grid gap-3 md:grid-cols-4">
<Form.Item name="current_waveform" label="雷电流波形">
<Select
options={[
{ value: "heidler", label: "Heidler" },
{ value: "double_slope", label: "双斜角" },
{ value: "double_exponential", label: "双指数" },
]}
/>
</Form.Item>
<Form.Item name="flashover_method" label="闪络判据">
<Select
options={[
{ value: "guideline", label: "规程法" },
{ value: "intersection", label: "相交法" },
{ value: "leader_development", label: "先导发展法" },
]}
/>
</Form.Item>
<Form.Item name="altitude_correction" label="海拔修正">
<Select
options={[
{ value: "none", label: "无" },
{ value: "formula1", label: "推荐公式1" },
{ value: "formula2", label: "推荐公式2" },
]}
/>
</Form.Item>
<Form.Item name="induced_voltage_formula" label="感应电压公式">
<Select
options={[
{ value: "formula1", label: "公式1" },
{ value: "formula2", label: "公式2" },
]}
/>
</Form.Item>
<Form.Item name="head_time_min_us" label="波头时间最小(μs)">
<InputNumber min={0.1} step={0.1} precision={2} className="w-full" />
</Form.Item>
<Form.Item name="head_time_max_us" label="波头时间最大(μs)">
<InputNumber min={0.1} step={0.1} precision={2} className="w-full" />
</Form.Item>
<Form.Item name="head_time_step_us" label="波头步长(μs)">
<InputNumber min={0.05} step={0.05} precision={2} className="w-full" />
</Form.Item>
<Form.Item name="tail_time_min_us" label="波尾时间最小(μs)">
<InputNumber min={1} step={1} precision={2} className="w-full" />
</Form.Item>
<Form.Item name="tail_time_max_us" label="波尾时间最大(μs)">
<InputNumber min={1} step={1} precision={2} className="w-full" />
</Form.Item>
<Form.Item name="tail_time_step_us" label="波尾步长(μs)">
<InputNumber min={0.5} step={0.5} precision={2} className="w-full" />
</Form.Item>
</div>
</>
) : null}
</Form>
);
}
@@ -0,0 +1,351 @@
"use client";
import { Descriptions, Empty, Modal, Space, Table, Tag, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import type {
FlAnalysisJobDetail,
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
import {
formatAltitudeCorrection,
formatCurrentWaveform,
formatExternalAdapter,
formatFlashoverMethod,
formatInducedVoltageFormula,
formatRangeSummary,
formatRiskLevel,
readCurrentRisk,
readCurrentScore,
readMitigationActions,
readMultiPhaseResults,
readObject,
readOptionalNumber,
readOptionalString,
readPhaseResults,
readReasonDetails,
readScanPoints,
readSelectedCase,
readString,
readWorkflow,
riskColor,
waveformJobType,
type MitigationAction,
type MultiPhaseResult,
type PhaseResult,
type ReasonDetail,
type ScanPoint,
} from "./types";
const reasonDetailColumns: ColumnsType<ReasonDetail> = [
{ title: "因子", dataIndex: "label", width: 180 },
{
title: "当前值",
key: "value",
render: (_value, row) => (row.value === undefined || row.value === null ? "-" : String(row.value)),
},
{
title: "标准值",
key: "standard_value",
render: (_value, row) => (row.standard_value === undefined || row.standard_value === null ? "-" : String(row.standard_value)),
},
{
title: "档次",
dataIndex: "grade",
width: 90,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "是否命中",
dataIndex: "triggered",
width: 100,
render: (value: boolean | undefined) => (value ? <Tag color="red"></Tag> : <Tag></Tag>),
},
];
const mitigationActionColumns: ColumnsType<MitigationAction> = [
{ title: "动作", dataIndex: "label", width: 160 },
{ title: "建议", dataIndex: "summary" },
{
title: "当前值",
key: "current_value",
render: (_value, row) => (row.current_value === undefined || row.current_value === null ? "-" : String(row.current_value)),
width: 100,
},
{
title: "目标值",
key: "target_value",
render: (_value, row) => (row.target_value === undefined || row.target_value === null ? "-" : String(row.target_value)),
width: 100,
},
];
const scanPointColumns: ColumnsType<ScanPoint> = [
{ title: "波头(μs)", dataIndex: "head_time_us", width: 100, render: (value: number | null | undefined) => value ?? "-" },
{ title: "波尾(μs)", dataIndex: "tail_time_us", width: 100, render: (value: number | null | undefined) => value ?? "-" },
{
title: "风险",
dataIndex: "risk_level",
width: 100,
render: (value: string | null | undefined) => <Tag color={riskColor(value)}>{formatRiskLevel(value)}</Tag>,
},
{ title: "得分", dataIndex: "score", width: 90, render: (value: number | null | undefined) => value ?? "-" },
{
title: "反击跳闸率",
dataIndex: "counterstrike_trip_rate",
width: 120,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "绕击跳闸率",
dataIndex: "shielding_trip_rate",
width: 120,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "闪络相",
dataIndex: "flashover_phase",
width: 160,
render: (value: string | null | undefined) => value || "-",
},
];
const phaseResultColumns: ColumnsType<PhaseResult> = [
{ title: "相别", dataIndex: "phase", width: 120 },
{ title: "回路", dataIndex: "circuit", width: 90, render: (value: string | null | undefined) => value || "-" },
{
title: "A/B/C绕击耐雷水平(kA)",
dataIndex: "shielding_withstand_ka",
width: 180,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "A/B/C绕击跳闸率",
dataIndex: "shielding_trip_rate",
width: 140,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "反击耐雷水平(kA)",
dataIndex: "counterstrike_withstand_ka",
width: 140,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "反击跳闸率",
dataIndex: "counterstrike_trip_rate",
width: 120,
render: (value: number | null | undefined) => value ?? "-",
},
];
const multiPhaseColumns: ColumnsType<MultiPhaseResult> = [
{ title: "相数组合", dataIndex: "label", width: 100 },
{ title: "闪络相", dataIndex: "flashover_phase", render: (value: string | null | undefined) => value || "-" },
{
title: "反击耐雷水平(kA)",
dataIndex: "counterstrike_withstand_ka",
width: 140,
render: (value: number | null | undefined) => value ?? "-",
},
{
title: "跳闸率",
dataIndex: "trip_rate",
width: 120,
render: (value: number | null | undefined) => value ?? "-",
},
];
type DetailModalProps = {
open: boolean;
detailRow: FlAnalysisTowerResultSummary | null;
selectedJob: FlAnalysisJobSummary | null;
selectedJobDetail: FlAnalysisJobDetail | null;
onClose: () => void;
};
export function DetailModal({
open,
detailRow,
selectedJob,
selectedJobDetail,
onClose,
}: DetailModalProps) {
const detailResultObject = readObject(detailRow?.result_json);
const reasonDetails = readReasonDetails(detailRow);
const mitigationActions = readMitigationActions(detailRow);
const scanPoints = readScanPoints(detailRow);
const phaseResults = readPhaseResults(detailRow);
const multiPhaseResults = readMultiPhaseResults(detailRow);
const detailWorkflow = readWorkflow(detailRow);
const detailSelectedCase = readSelectedCase(detailRow);
const selectedJobDetailWaveformType = waveformJobType(selectedJobDetail ?? selectedJob);
const detailExternalExecution = readObject(detailResultObject.external_execution);
return (
<Modal
title={detailRow ? `${selectedJob?.job_type === "mitigation" ? "高风险原因" : "计算详情"} - ${detailRow.tower_no}` : "计算详情"}
open={open}
onCancel={onClose}
footer={null}
width={980}
>
{!detailRow ? (
<Empty description="暂无杆塔详情" />
) : (
<Space direction="vertical" size={16} className="flex w-full">
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="当前结论">{detailRow.summary_text || "-"}</Descriptions.Item>
<Descriptions.Item label={selectedJob?.job_type === "mitigation" ? "预期风险/风险等级" : "风险等级"}>
<Tag color={riskColor(detailRow.risk_level)}>{formatRiskLevel(detailRow.risk_level)}</Tag>
</Descriptions.Item>
{selectedJob?.job_type === "mitigation" ? (
<>
<Descriptions.Item label="当前风险">
<Tag color={riskColor(readCurrentRisk(detailRow))}>{formatRiskLevel(readCurrentRisk(detailRow))}</Tag>
</Descriptions.Item>
<Descriptions.Item label="改造结论">{readString(detailResultObject, "recommendation_result")}</Descriptions.Item>
</>
) : selectedJobDetailWaveformType === "normal" || selectedJobDetailWaveformType === "tongtiao" ? (
<>
<Descriptions.Item label="最不利点(μs)">
{typeof detailSelectedCase.head_time_us === "number" && typeof detailSelectedCase.tail_time_us === "number"
? `${detailSelectedCase.head_time_us}/${detailSelectedCase.tail_time_us}`
: "-"}
</Descriptions.Item>
<Descriptions.Item label="扫描点数">{String(detailWorkflow.scan_point_count ?? "-")}</Descriptions.Item>
<Descriptions.Item label="雷电流波形">{formatCurrentWaveform(detailWorkflow.current_waveform)}</Descriptions.Item>
<Descriptions.Item label="闪络判据">{formatFlashoverMethod(detailWorkflow.flashover_method)}</Descriptions.Item>
<Descriptions.Item label="海拔修正">{formatAltitudeCorrection(detailWorkflow.altitude_correction)}</Descriptions.Item>
<Descriptions.Item label="感应电压公式">{formatInducedVoltageFormula(detailWorkflow.induced_voltage_formula)}</Descriptions.Item>
<Descriptions.Item label="波头范围">{formatRangeSummary(detailWorkflow.head_time_range_us)}</Descriptions.Item>
<Descriptions.Item label="波尾范围">{formatRangeSummary(detailWorkflow.tail_time_range_us)}</Descriptions.Item>
<Descriptions.Item label="执行适配器">
{formatExternalAdapter(readOptionalString(detailExternalExecution, "adapter"))}
</Descriptions.Item>
<Descriptions.Item label="ATP模型">
{readOptionalString(detailExternalExecution, "model_name") || readOptionalString(detailExternalExecution, "model_code") || "-"}
</Descriptions.Item>
<Descriptions.Item label="模型版本">
{typeof readOptionalNumber(detailExternalExecution, "version_no") === "number"
? `v${readOptionalNumber(detailExternalExecution, "version_no")}`
: "-"}
</Descriptions.Item>
{selectedJobDetailWaveformType === "tongtiao" ? (
<>
<Descriptions.Item label="主导相组">{readOptionalString(detailResultObject, "dominant_phase_set") ?? "-"}</Descriptions.Item>
<Descriptions.Item label="闪络相">{readOptionalString(detailResultObject, "flashover_phase") ?? "-"}</Descriptions.Item>
</>
) : null}
</>
) : null}
<Descriptions.Item label="原因分析" span={2}>
{readString(detailResultObject, "cause_analysis")}
</Descriptions.Item>
<Descriptions.Item label="措施建议" span={2}>
{readString(detailResultObject, "mitigation_recommendation")}
</Descriptions.Item>
<Descriptions.Item label="当前得分">
{selectedJob?.job_type === "mitigation"
? String(readCurrentScore(detailRow) ?? "-")
: String(readOptionalNumber(detailResultObject, "score") ?? "-")}
</Descriptions.Item>
<Descriptions.Item label="预期得分/得分">
{selectedJob?.job_type === "mitigation"
? String(readOptionalNumber(detailResultObject, "expected_score") ?? "-")
: String(readOptionalNumber(detailResultObject, "score") ?? "-")}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
{reasonDetails.length === 0 ? (
<Empty description="暂无细项分级" />
) : (
<Table<ReasonDetail>
rowKey="code"
columns={reasonDetailColumns}
dataSource={reasonDetails}
pagination={false}
size="small"
/>
)}
{selectedJobDetailWaveformType === "normal" || selectedJobDetailWaveformType === "tongtiao" ? (
<>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
{scanPoints.length === 0 ? (
<Empty description="当前结果未生成扫描点" />
) : (
<Table<ScanPoint>
rowKey={(row) => `${row.head_time_us ?? "-"}-${row.tail_time_us ?? "-"}`}
columns={scanPointColumns}
dataSource={scanPoints}
pagination={false}
size="small"
scroll={{ x: 1000 }}
/>
)}
</>
) : null}
{selectedJobDetailWaveformType === "tongtiao" ? (
<>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
{phaseResults.length === 0 ? (
<Empty description="当前结果未生成相别结果" />
) : (
<Table<PhaseResult>
rowKey="phase"
columns={phaseResultColumns}
dataSource={phaseResults}
pagination={false}
size="small"
scroll={{ x: 1200 }}
/>
)}
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
{multiPhaseResults.length === 0 ? (
<Empty description="当前结果未生成多相结果" />
) : (
<Table<MultiPhaseResult>
rowKey="label"
columns={multiPhaseColumns}
dataSource={multiPhaseResults}
pagination={false}
size="small"
scroll={{ x: 900 }}
/>
)}
</>
) : null}
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
{mitigationActions.length === 0 ? (
<Empty description="当前结果未生成改造动作" />
) : (
<Table<MitigationAction>
rowKey="code"
columns={mitigationActionColumns}
dataSource={mitigationActions}
pagination={false}
size="small"
/>
)}
</Space>
)}
</Modal>
);
}
@@ -0,0 +1,90 @@
"use client";
import { Empty, Spin, Space, Tag, Typography } from "antd";
import { Card } from "@/components/ui-antd";
import type { FlAnalysisJobSummary } from "@/types/auth";
import {
formatDateTime,
formatJobType,
mitigationMode,
statusColor,
} from "./types";
type JobCardListProps = {
jobs: FlAnalysisJobSummary[];
selectedJobId: string | null;
loading: boolean;
onSelect: (jobId: string) => void;
};
export function JobCardList({ jobs, selectedJobId, loading, onSelect }: JobCardListProps) {
if (loading) {
return <Spin />;
}
if (jobs.length === 0) {
return <Empty description="暂无防雷分析任务" />;
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{jobs.map((job) => {
const selected = job.id === selectedJobId;
const lineName = job.line_name || job.line_code || "-";
return (
<button
key={job.id}
type="button"
className={`w-full cursor-pointer rounded-lg border bg-white p-3 text-left transition hover:border-blue-400 hover:shadow-sm ${
selected ? "border-blue-500 shadow-sm ring-1 ring-blue-500" : "border-gray-200"
}`}
onClick={() => {
onSelect(job.id);
}}
>
<Space direction="vertical" size={8} className="flex w-full">
<Space size={[6, 6]} wrap>
<Tag color={statusColor(job.status)}>{job.status}</Tag>
<Tag>{formatJobType(job.job_type, mitigationMode(job))}</Tag>
</Space>
<div>
<Typography.Text strong className="line-clamp-1">
{job.job_name || lineName}
</Typography.Text>
<Typography.Text type="secondary" className="block line-clamp-1">
{lineName}
</Typography.Text>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-500">
<span>{formatDateTime(job.create_date)}</span>
<span>{formatDateTime(job.finished_at)}</span>
<span>{job.total_tower_count}</span>
<span>{job.result_tower_count}</span>
</div>
</Space>
</button>
);
})}
</div>
);
}
export function JobListCard(props: JobCardListProps) {
return (
<Card>
<Space direction="vertical" size={12} className="flex w-full">
<div>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<JobCardList {...props} />
</Space>
</Card>
);
}
@@ -0,0 +1,248 @@
"use client";
import { Alert, Button, Descriptions, Divider, Empty, Space, Spin, Tag } from "antd";
import type {
FlAnalysisJobDetail,
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
import {
formatAltitudeCorrection,
formatCurrentWaveform,
formatDateTime,
formatExternalAdapter,
formatFlashoverMethod,
formatInducedVoltageFormula,
formatJobType,
formatRangeSummary,
mitigationMode,
readObject,
readOptionalNumber,
readOptionalString,
selectedTowerCount,
statusColor,
type WorkflowSummary,
} from "./types";
type JobDetailPanelProps = {
selectedJob: FlAnalysisJobSummary | null;
selectedJobDetail: FlAnalysisJobDetail | null;
loading: boolean;
error: unknown;
canManage: boolean;
canCreateMitigation: boolean;
canCreateScenario: boolean;
canCreateReport: boolean;
canDownloadResults: boolean;
selectedJobDetailWaveformType: "normal" | "tongtiao" | null;
candidateMitigationRows: FlAnalysisTowerResultSummary[];
candidateScenarioRows: FlAnalysisTowerResultSummary[];
candidateScenarioBaseJobs: FlAnalysisJobSummary[];
candidateReportRows: FlAnalysisTowerResultSummary[];
startPending: boolean;
downloadResultsPending: boolean;
downloadReportPending: boolean;
onStart: () => void;
onOpenMitigation: () => void;
onOpenScenario: () => void;
onOpenReport: () => void;
onDownloadResults: () => void;
onDownloadReport: () => void;
};
export function JobDetailPanel({
selectedJob,
selectedJobDetail,
loading,
error,
canManage,
canCreateMitigation,
canCreateScenario,
canCreateReport,
canDownloadResults,
selectedJobDetailWaveformType,
candidateMitigationRows,
candidateScenarioRows,
candidateScenarioBaseJobs,
candidateReportRows,
startPending,
downloadResultsPending,
downloadReportPending,
onStart,
onOpenMitigation,
onOpenScenario,
onOpenReport,
onDownloadResults,
onDownloadReport,
}: JobDetailPanelProps) {
if (loading) {
return <Spin />;
}
if (error) {
return (
<Alert
type="error"
showIcon
message={error instanceof Error ? error.message : "任务详情加载失败"}
/>
);
}
if (!selectedJobDetail || !selectedJob) {
return <Empty description="暂无任务详情" />;
}
const selectedJobExecutionOptions = readObject(selectedJobDetail.execution_options_json);
const selectedJobSummary = readObject(selectedJobDetail.result_summary_json);
const selectedJobWorkflow = readObject(selectedJobDetail.result_summary_json).workflow as WorkflowSummary | undefined;
const selectedJobExternalModelCode = readOptionalString(selectedJobSummary, "external_model_code");
const selectedJobExternalModelName = readOptionalString(selectedJobSummary, "external_model_name");
const selectedJobExternalVersionNo = readOptionalNumber(selectedJobSummary, "external_version_no");
const sourceJobId = readOptionalString(selectedJobExecutionOptions, "source_job_id");
const scenarioBaseJobName = readOptionalString(selectedJobExecutionOptions, "base_job_name");
const scenarioBaseJobType = readOptionalString(selectedJobExecutionOptions, "base_job_type");
const reportSourceJobType = readOptionalString(selectedJobSummary, "source_job_type");
const reportSourceJobName = readOptionalString(selectedJobSummary, "source_job_name");
const reportMitigationJobName = readOptionalString(selectedJobSummary, "mitigation_job_name");
const reportScenarioJobName = readOptionalString(selectedJobSummary, "scenario_job_name");
const reportDocumentName = readOptionalString(selectedJobSummary, "document_filename");
return (
<Space direction="vertical" size={16} className="flex w-full">
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="任务名称">{selectedJobDetail.job_name || "-"}</Descriptions.Item>
<Descriptions.Item label="任务状态">
<Tag color={statusColor(selectedJobDetail.status)}>{selectedJobDetail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="线路">{selectedJobDetail.line_name || selectedJobDetail.line_code || "-"}</Descriptions.Item>
<Descriptions.Item label="任务类型">{formatJobType(selectedJobDetail.job_type, mitigationMode(selectedJobDetail))}</Descriptions.Item>
<Descriptions.Item label="结果杆塔数">{selectedJobDetail.result_tower_count}</Descriptions.Item>
<Descriptions.Item label="完成时间">{formatDateTime(selectedJobDetail.finished_at)}</Descriptions.Item>
<Descriptions.Item label="风险计数" span={2}>
{JSON.stringify(selectedJobDetail.result_summary_json?.risk_counts ?? {}, null, 2)}
</Descriptions.Item>
{selectedJobDetail.job_type === "mitigation" ? (
<>
<Descriptions.Item label="前驱风险任务">{sourceJobId || "-"}</Descriptions.Item>
<Descriptions.Item label="选塔数">{String(selectedTowerCount(selectedJobDetail) || "-")}</Descriptions.Item>
<Descriptions.Item label="模式">{mitigationMode(selectedJobDetail) ? "非建线" : "常规建线"}</Descriptions.Item>
<Descriptions.Item label="需装避雷器数">
{String(readObject(selectedJobDetail.result_summary_json).arrester_required_count ?? "-")}
</Descriptions.Item>
</>
) : selectedJobDetail.job_type === "scenario" ? (
<>
<Descriptions.Item label="前驱措施任务">{sourceJobId || "-"}</Descriptions.Item>
<Descriptions.Item label="选塔数">{String(selectedTowerCount(selectedJobDetail) || "-")}</Descriptions.Item>
<Descriptions.Item label="复用计算口径">
{scenarioBaseJobType ? formatJobType(scenarioBaseJobType) : "-"}
</Descriptions.Item>
<Descriptions.Item label="基线计算任务">{scenarioBaseJobName || "-"}</Descriptions.Item>
</>
) : selectedJobDetail.job_type === "report" ? (
<>
<Descriptions.Item label="报告来源">
{reportSourceJobType ? formatJobType(reportSourceJobType) : "-"}
{reportSourceJobName ? ` / ${reportSourceJobName}` : ""}
</Descriptions.Item>
<Descriptions.Item label="选塔数">
{String(readObject(selectedJobDetail.result_summary_json).selected_tower_count ?? "-")}
</Descriptions.Item>
<Descriptions.Item label="关联措施任务">{reportMitigationJobName || "未关联"}</Descriptions.Item>
<Descriptions.Item label="关联复算任务">{reportScenarioJobName || "未关联"}</Descriptions.Item>
<Descriptions.Item label="文档名" span={2}>{reportDocumentName || "-"}</Descriptions.Item>
</>
) : selectedJobDetailWaveformType === "normal" || selectedJobDetailWaveformType === "tongtiao" ? (
<>
<Descriptions.Item label="适配器">{formatExternalAdapter(selectedJobDetail.external_adapter)}</Descriptions.Item>
<Descriptions.Item label="ATP模型">
{selectedJobExternalModelName || selectedJobExternalModelCode || "-"}
{selectedJobExternalModelName && selectedJobExternalModelCode ? ` / ${selectedJobExternalModelCode}` : ""}
</Descriptions.Item>
<Descriptions.Item label="模型版本">
{typeof selectedJobExternalVersionNo === "number" ? `v${selectedJobExternalVersionNo}` : "-"}
</Descriptions.Item>
<Descriptions.Item label="平均得分">{String(selectedJobDetail.result_summary_json?.score_average ?? "-")}</Descriptions.Item>
<Descriptions.Item label="平均扫描点">{String(readObject(selectedJobDetail.result_summary_json).scan_point_average ?? "-")}</Descriptions.Item>
<Descriptions.Item label="雷电流波形">{formatCurrentWaveform(selectedJobWorkflow?.current_waveform)}</Descriptions.Item>
<Descriptions.Item label="闪络判据">{formatFlashoverMethod(selectedJobWorkflow?.flashover_method)}</Descriptions.Item>
<Descriptions.Item label="海拔修正">{formatAltitudeCorrection(selectedJobWorkflow?.altitude_correction)}</Descriptions.Item>
<Descriptions.Item label="感应电压公式">{formatInducedVoltageFormula(selectedJobWorkflow?.induced_voltage_formula)}</Descriptions.Item>
<Descriptions.Item label="波头范围">{formatRangeSummary(selectedJobWorkflow?.head_time_range_us)}</Descriptions.Item>
<Descriptions.Item label="波尾范围">{formatRangeSummary(selectedJobWorkflow?.tail_time_range_us)}</Descriptions.Item>
</>
) : (
<>
<Descriptions.Item label="平均得分">{String(selectedJobDetail.result_summary_json?.score_average ?? "-")}</Descriptions.Item>
<Descriptions.Item label="适配器">{String(selectedJobDetail.external_adapter || "-")}</Descriptions.Item>
</>
)}
</Descriptions>
{canManage || selectedJob.job_type === "report" ? (
<Space size={12} wrap split={<Divider type="vertical" />}>
{canManage ? (
<Space wrap>
<Button onClick={onStart} loading={startPending}>
{selectedJob.status === "success" ? "重新执行任务" : "启动任务"}
</Button>
</Space>
) : null}
{canManage && (canCreateMitigation || canCreateScenario || canCreateReport) ? (
<Space wrap>
{canCreateMitigation ? (
<Button
type="primary"
disabled={candidateMitigationRows.length === 0}
onClick={onOpenMitigation}
>
</Button>
) : null}
{canCreateScenario ? (
<Button
disabled={candidateScenarioRows.length === 0 || candidateScenarioBaseJobs.length === 0}
onClick={onOpenScenario}
>
</Button>
) : null}
{canCreateReport ? (
<Button
disabled={candidateReportRows.length === 0}
onClick={onOpenReport}
>
</Button>
) : null}
</Space>
) : null}
<Space wrap>
{selectedJob.job_type !== "report" ? (
<Button
onClick={onDownloadResults}
loading={downloadResultsPending}
disabled={!canDownloadResults}
>
</Button>
) : null}
{selectedJob.job_type === "report" ? (
<Button
type="primary"
onClick={onDownloadReport}
loading={downloadReportPending}
disabled={selectedJob.status !== "success"}
>
</Button>
) : null}
</Space>
</Space>
) : null}
</Space>
);
}
@@ -0,0 +1,127 @@
"use client";
import { Alert, Checkbox, Empty, Form, Input, Modal, Space, Table, Tag, Typography } from "antd";
import type { FormInstance } from "antd";
import type {
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
import {
formatRiskLevel,
readObject,
readString,
riskColor,
type MitigationFormValues,
} from "./types";
type MitigationModalProps = {
open: boolean;
selectedJob: FlAnalysisJobSummary | null;
rows: FlAnalysisTowerResultSummary[];
selectedTowerIds: string[];
form: FormInstance<MitigationFormValues>;
submitting: boolean;
onSelectedTowerIdsChange: (towerIds: string[]) => void;
onSubmit: (values: MitigationFormValues) => void;
onCancel: () => void;
};
export function MitigationModal({
open,
selectedJob,
rows,
selectedTowerIds,
form,
submitting,
onSelectedTowerIdsChange,
onSubmit,
onCancel,
}: MitigationModalProps) {
return (
<Modal
title={selectedJob ? `措施推荐 - ${selectedJob.job_name || selectedJob.line_name || selectedJob.line_code}` : "措施推荐"}
open={open}
width={1080}
confirmLoading={submitting}
okText="创建并启动措施任务"
onCancel={() => {
if (submitting) {
return;
}
onCancel();
}}
onOk={() => {
form.submit();
}}
>
<Space direction="vertical" size={16} className="flex w-full">
<Alert
type="info"
showIcon
message="源端迁移口径:仅允许从已有风险结果中选择高风险杆塔,生成措施推荐任务。"
/>
<Form<MitigationFormValues>
form={form}
layout="vertical"
onFinish={onSubmit}
initialValues={{ non_construction: false }}
>
<Form.Item
name="job_name"
label="任务名称"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input placeholder="措施推荐任务名称" />
</Form.Item>
<Form.Item name="non_construction" valuePropName="checked">
<Checkbox>线</Checkbox>
</Form.Item>
</Form>
{rows.length === 0 ? (
<Empty description="当前风险任务没有中高风险杆塔,无法生成措施推荐任务" />
) : (
<>
<Typography.Text type="secondary">
{rows.length}
</Typography.Text>
<Table<FlAnalysisTowerResultSummary>
rowKey="tower_id"
size="small"
pagination={{ pageSize: 8, showSizeChanger: false, hideOnSinglePage: false }}
rowSelection={{
selectedRowKeys: selectedTowerIds,
onChange: (keys) => {
onSelectedTowerIdsChange(keys.map((item) => String(item)));
},
}}
columns={[
{ title: "杆塔号", dataIndex: "tower_no", width: 120 },
{
title: "风险等级",
dataIndex: "risk_level",
width: 120,
render: (value: string | null) => <Tag color={riskColor(value)}>{formatRiskLevel(value)}</Tag>,
},
{
title: "高风险原因",
key: "cause_analysis",
render: (_value, row) => readString(readObject(row.result_json), "cause_analysis"),
},
{
title: "当前建议",
key: "mitigation_recommendation",
render: (_value, row) => readString(readObject(row.result_json), "mitigation_recommendation"),
},
]}
dataSource={rows}
scroll={{ x: 1000 }}
/>
</>
)}
</Space>
</Modal>
);
}
@@ -0,0 +1,123 @@
"use client";
import { Alert, Empty, Form, Input, Modal, Space, Table, Tag, Typography } from "antd";
import type { FormInstance } from "antd";
import type {
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
import {
formatRiskLevel,
readObject,
readString,
riskColor,
type ReportFormValues,
} from "./types";
type ReportModalProps = {
open: boolean;
selectedJob: FlAnalysisJobSummary | null;
rows: FlAnalysisTowerResultSummary[];
selectedTowerIds: string[];
form: FormInstance<ReportFormValues>;
submitting: boolean;
onSelectedTowerIdsChange: (towerIds: string[]) => void;
onSubmit: (values: ReportFormValues) => void;
onCancel: () => void;
};
export function ReportModal({
open,
selectedJob,
rows,
selectedTowerIds,
form,
submitting,
onSelectedTowerIdsChange,
onSubmit,
onCancel,
}: ReportModalProps) {
return (
<Modal
title={selectedJob ? `报告生成 - ${selectedJob.job_name || selectedJob.line_name || selectedJob.line_code}` : "报告生成"}
open={open}
width={1080}
confirmLoading={submitting}
okText="创建并启动报告任务"
onCancel={() => {
if (submitting) {
return;
}
onCancel();
}}
onOk={() => {
form.submit();
}}
>
<Space direction="vertical" size={16} className="flex w-full">
<Alert
type="info"
showIcon
message="源端迁移口径:报告任务挂靠在已完成的风险评估或措施推荐结果上,并允许按杆塔缩小纳入报告的范围;若已存在关联的加装避雷器复算任务,报告会自动并入“采取措施后的计算结果表”。"
/>
<Form<ReportFormValues>
form={form}
layout="vertical"
onFinish={onSubmit}
>
<Form.Item
name="job_name"
label="任务名称"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input placeholder="报告任务名称" />
</Form.Item>
</Form>
{rows.length === 0 ? (
<Empty description="当前任务没有可纳入报告的杆塔结果" />
) : (
<>
<Typography.Text type="secondary">
{rows.length}
</Typography.Text>
<Table<FlAnalysisTowerResultSummary>
rowKey="tower_id"
size="small"
pagination={{ pageSize: 8, showSizeChanger: false, hideOnSinglePage: false }}
rowSelection={{
selectedRowKeys: selectedTowerIds,
onChange: (keys) => {
onSelectedTowerIdsChange(keys.map((item) => String(item)));
},
}}
columns={[
{ title: "杆塔号", dataIndex: "tower_no", width: 120 },
{
title: "风险等级",
dataIndex: "risk_level",
width: 120,
render: (value: string | null) => <Tag color={riskColor(value)}>{formatRiskLevel(value)}</Tag>,
},
{
title: "高风险原因",
key: "cause_analysis",
render: (_value, row) => readString(readObject(row.result_json), "cause_analysis"),
},
{
title: "措施建议",
key: "mitigation_recommendation",
render: (_value, row) => readString(readObject(row.result_json), "mitigation_recommendation"),
},
]}
dataSource={rows}
scroll={{ x: 1000 }}
/>
</>
)}
</Space>
</Modal>
);
}
@@ -0,0 +1,253 @@
"use client";
import { useMemo } from "react";
import { Alert, Button, Empty, Spin, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import type {
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
import {
formatRiskLevel,
readCurrentRisk,
readCurrentScore,
readObject,
readOptionalNumber,
readOptionalString,
readSelectedCase,
readString,
riskColor,
} from "./types";
type ResultTableProps = {
selectedJob: FlAnalysisJobSummary | null;
selectedWaveformJobType: "normal" | "tongtiao" | null;
rows: FlAnalysisTowerResultSummary[];
loading: boolean;
error: unknown;
onOpenDetail: (row: FlAnalysisTowerResultSummary) => void;
};
export function ResultTable({
selectedJob,
selectedWaveformJobType,
rows,
loading,
error,
onOpenDetail,
}: ResultTableProps) {
const columns = useMemo<ColumnsType<FlAnalysisTowerResultSummary>>(() => {
const resultColumns: ColumnsType<FlAnalysisTowerResultSummary> = [
{
title: "序号",
dataIndex: "seq_no",
width: 80,
},
{
title: "杆塔号",
dataIndex: "tower_no",
width: 120,
},
{
title: "塔型",
dataIndex: "tower_type",
width: 100,
render: (value: string | null) => value || "-",
},
];
if (selectedJob?.job_type === "mitigation") {
resultColumns.push(
{
title: "当前风险",
key: "current_risk_level",
width: 110,
render: (_value, row) => <Tag color={riskColor(readCurrentRisk(row))}>{formatRiskLevel(readCurrentRisk(row))}</Tag>,
},
{
title: "预期风险",
dataIndex: "risk_level",
width: 110,
render: (value: string | null) => <Tag color={riskColor(value)}>{formatRiskLevel(value)}</Tag>,
},
);
} else {
resultColumns.push({
title: "风险等级",
dataIndex: "risk_level",
width: 120,
render: (value: string | null) => <Tag color={riskColor(value)}>{formatRiskLevel(value)}</Tag>,
});
}
if (selectedWaveformJobType === "normal" || selectedWaveformJobType === "tongtiao") {
resultColumns.push(
{
title: "最不利点(μs)",
key: "selected_case",
width: 160,
render: (_value, row) => {
const selectedCase = readSelectedCase(row);
const head = selectedCase.head_time_us;
const tail = selectedCase.tail_time_us;
if (typeof head !== "number" || typeof tail !== "number") {
return "-";
}
return `${head}/${tail}`;
},
},
);
}
if (selectedWaveformJobType === "normal") {
resultColumns.push(
{
title: "反击耐雷水平(kA)",
key: "counterstrike_withstand_ka",
width: 140,
render: (_value, row) => readOptionalNumber(readObject(row.result_json), "counterstrike_withstand_ka") ?? "-",
},
{
title: "反击跳闸率",
key: "counterstrike_trip_rate",
width: 120,
render: (_value, row) => readOptionalNumber(readObject(row.result_json), "counterstrike_trip_rate") ?? "-",
},
{
title: "绕击耐雷水平(kA)",
key: "shielding_withstand_ka",
width: 140,
render: (_value, row) => readOptionalNumber(readObject(row.result_json), "shielding_withstand_ka") ?? "-",
},
{
title: "绕击跳闸率",
key: "shielding_trip_rate",
width: 120,
render: (_value, row) => readOptionalNumber(readObject(row.result_json), "shielding_trip_rate") ?? "-",
},
);
}
if (selectedWaveformJobType === "tongtiao") {
resultColumns.push(
{
title: "主导相组",
key: "dominant_phase_set",
width: 120,
render: (_value, row) => readOptionalString(readObject(row.result_json), "dominant_phase_set") ?? "-",
},
{
title: "闪络相",
key: "flashover_phase",
width: 160,
render: (_value, row) => readOptionalString(readObject(row.result_json), "flashover_phase") ?? "-",
},
{
title: "同跳跳闸率",
key: "counterstrike_trip_rate",
width: 120,
render: (_value, row) => readOptionalNumber(readObject(row.result_json), "counterstrike_trip_rate") ?? "-",
},
);
}
resultColumns.push(
{
title: "综合结论",
dataIndex: "summary_text",
ellipsis: true,
},
{
title: "高风险原因",
key: "cause_analysis",
ellipsis: true,
render: (_value, row) => readString(readObject(row.result_json), "cause_analysis"),
},
{
title: "措施建议",
key: "mitigation_recommendation",
ellipsis: true,
render: (_value, row) => readString(readObject(row.result_json), "mitigation_recommendation"),
},
);
if (selectedJob?.job_type === "mitigation") {
resultColumns.push(
{
title: "改造结论",
key: "recommendation_result",
width: 140,
render: (_value, row) => readString(readObject(row.result_json), "recommendation_result"),
},
{
title: "当前得分",
key: "current_score",
width: 100,
render: (_value, row) => {
const value = readCurrentScore(row);
return value === null ? "-" : String(value);
},
},
);
}
resultColumns.push(
{
title: "得分",
key: "score",
width: 90,
render: (_value, row) => {
const value = readObject(row.result_json).score;
return typeof value === "number" ? value : "-";
},
},
{
title: "详情",
key: "actions",
width: 110,
render: (_value, row) => (
<Button
size="small"
onClick={() => {
onOpenDetail(row);
}}
>
</Button>
),
},
);
return resultColumns;
}, [onOpenDetail, selectedJob?.job_type, selectedWaveformJobType]);
if (loading) {
return <Spin />;
}
if (error) {
return (
<Alert
type="error"
showIcon
message={error instanceof Error ? error.message : "结果表加载失败"}
/>
);
}
if (rows.length === 0) {
return <Empty description="当前任务暂无分级结果" />;
}
return (
<Table<FlAnalysisTowerResultSummary>
rowKey="id"
columns={columns}
dataSource={rows}
pagination={{ pageSize: 20, showSizeChanger: false, hideOnSinglePage: false }}
scroll={{ x: 1400 }}
/>
);
}
@@ -0,0 +1,148 @@
"use client";
import { Alert, Empty, Form, Input, Modal, Select, Space, Table, Tag, Typography } from "antd";
import type { FormInstance } from "antd";
import type {
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
import {
formatDateTime,
formatJobType,
formatRiskLevel,
readObject,
readString,
riskColor,
type ScenarioFormValues,
} from "./types";
type ScenarioModalProps = {
open: boolean;
selectedJob: FlAnalysisJobSummary | null;
rows: FlAnalysisTowerResultSummary[];
baseJobs: FlAnalysisJobSummary[];
selectedTowerIds: string[];
form: FormInstance<ScenarioFormValues>;
submitting: boolean;
onSelectedTowerIdsChange: (towerIds: string[]) => void;
onSubmit: (values: ScenarioFormValues) => void;
onCancel: () => void;
};
export function ScenarioModal({
open,
selectedJob,
rows,
baseJobs,
selectedTowerIds,
form,
submitting,
onSelectedTowerIdsChange,
onSubmit,
onCancel,
}: ScenarioModalProps) {
return (
<Modal
title={selectedJob ? `加装避雷器复算 - ${selectedJob.job_name || selectedJob.line_name || selectedJob.line_code}` : "加装避雷器复算"}
open={open}
width={1080}
confirmLoading={submitting}
okText="创建并启动复算任务"
onCancel={() => {
if (submitting) {
return;
}
onCancel();
}}
onOk={() => {
form.submit();
}}
>
<Space direction="vertical" size={16} className="flex w-full">
<Alert
type="info"
showIcon
message="源端迁移口径:仅允许从措施推荐结果中继续选择仍为中高风险的杆塔,并复用一次已完成的普通/同跳计算链路,执行“补装避雷器后”的独立复算。"
/>
<Form<ScenarioFormValues>
form={form}
layout="vertical"
onFinish={onSubmit}
>
<Form.Item
name="job_name"
label="任务名称"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input placeholder="加装避雷器复算任务名称" />
</Form.Item>
<Form.Item
name="base_job_id"
label="复用计算任务"
rules={[{ required: true, message: "请选择复用的普通计算或同跳计算任务" }]}
>
<Select
placeholder="选择已成功完成的普通计算或同跳计算任务"
options={baseJobs.map((item) => ({
value: item.id,
label: `${item.job_name || item.line_name || item.line_code || item.id} / ${formatJobType(item.job_type)} / ${formatDateTime(item.finished_at)}`,
}))}
/>
</Form.Item>
</Form>
{baseJobs.length === 0 ? (
<Alert
type="warning"
showIcon
message="当前线路还没有可复用的普通计算或同跳计算成功任务。请先完成一次基线计算,再创建加装避雷器复算任务。"
/>
) : null}
{rows.length === 0 ? (
<Empty description="当前措施任务没有仍需复算的中高风险杆塔" />
) : (
<>
<Typography.Text type="secondary">
{rows.length}
</Typography.Text>
<Table<FlAnalysisTowerResultSummary>
rowKey="tower_id"
size="small"
pagination={{ pageSize: 8, showSizeChanger: false, hideOnSinglePage: false }}
rowSelection={{
selectedRowKeys: selectedTowerIds,
onChange: (keys) => {
onSelectedTowerIdsChange(keys.map((item) => String(item)));
},
}}
columns={[
{ title: "杆塔号", dataIndex: "tower_no", width: 120 },
{
title: "预期风险",
dataIndex: "risk_level",
width: 120,
render: (value: string | null) => <Tag color={riskColor(value)}>{formatRiskLevel(value)}</Tag>,
},
{
title: "高风险原因",
key: "cause_analysis",
render: (_value, row) => readString(readObject(row.result_json), "cause_analysis"),
},
{
title: "当前动作",
key: "mitigation_recommendation",
render: (_value, row) => readString(readObject(row.result_json), "mitigation_recommendation"),
},
]}
dataSource={rows}
scroll={{ x: 1000 }}
/>
</>
)}
</Space>
</Modal>
);
}
+356
View File
@@ -0,0 +1,356 @@
import type {
FlAnalysisJobDetail,
FlAnalysisJobSummary,
FlAnalysisTowerResultSummary,
} from "@/types/auth";
export type CreateJobFormValues = {
job_name: string;
line_id: string;
job_type: "normal" | "tongtiao" | "risk";
external_adapter: "placeholder" | "wine" | "atp";
atp_model_id: string;
current_waveform: "heidler" | "double_slope" | "double_exponential";
flashover_method: "guideline" | "intersection" | "leader_development";
altitude_correction: "none" | "formula1" | "formula2";
induced_voltage_formula: "formula1" | "formula2";
head_time_min_us: number;
head_time_max_us: number;
head_time_step_us: number;
tail_time_min_us: number;
tail_time_max_us: number;
tail_time_step_us: number;
};
export type MitigationFormValues = {
job_name: string;
non_construction: boolean;
};
export type ScenarioFormValues = {
job_name: string;
base_job_id: string;
};
export type ReportFormValues = {
job_name: string;
};
export type ReasonDetail = {
code: string;
label: string;
value?: unknown;
standard_value?: unknown;
grade?: number | null;
triggered?: boolean;
};
export type MitigationAction = {
code: string;
label: string;
summary: string;
current_value?: unknown;
target_value?: unknown;
unit?: string;
phases?: string[];
};
export type WorkflowRange = {
min?: number | null;
max?: number | null;
step?: number | null;
};
export type WorkflowSummary = {
current_waveform?: string;
flashover_method?: string;
altitude_correction?: string;
induced_voltage_formula?: string;
head_time_range_us?: WorkflowRange;
tail_time_range_us?: WorkflowRange;
scan_point_count?: number;
};
export type ScanPoint = {
head_time_us?: number | null;
tail_time_us?: number | null;
risk_level?: string | null;
score?: number | null;
counterstrike_withstand_ka?: number | null;
counterstrike_trip_rate?: number | null;
shielding_withstand_ka?: number | null;
shielding_trip_rate?: number | null;
flashover_phase?: string | null;
dominant_phase_set?: string | null;
};
export type SelectedCase = {
head_time_us?: number | null;
tail_time_us?: number | null;
risk_level?: string | null;
score?: number | null;
flashover_phase?: string | null;
dominant_phase_set?: string | null;
};
export type PhaseResult = {
phase: string;
circuit?: string | null;
shielding_withstand_ka?: number | null;
shielding_trip_rate?: number | null;
counterstrike_withstand_ka?: number | null;
counterstrike_trip_rate?: number | null;
};
export type MultiPhaseResult = {
phase_count: number;
label: string;
flashover_phase?: string | null;
counterstrike_withstand_ka?: number | null;
trip_rate?: number | null;
};
export const CREATE_JOB_DEFAULTS: CreateJobFormValues = {
job_name: "",
line_id: "",
job_type: "normal",
external_adapter: "placeholder",
atp_model_id: "",
current_waveform: "heidler",
flashover_method: "intersection",
altitude_correction: "none",
induced_voltage_formula: "formula1",
head_time_min_us: 2.6,
head_time_max_us: 2.6,
head_time_step_us: 0.1,
tail_time_min_us: 50,
tail_time_max_us: 50,
tail_time_step_us: 1,
};
export function formatRiskLevel(value: string | null | undefined): string {
if (value === "high") return "高风险";
if (value === "medium") return "中风险";
if (value === "low") return "低风险";
return value || "-";
}
export function formatJobType(jobType: string, nonConstruction = false): string {
if (jobType === "risk") return "风险评估";
if (jobType === "mitigation") return nonConstruction ? "措施推荐(非建线)" : "措施推荐";
if (jobType === "normal") return "普通计算";
if (jobType === "tongtiao") return "同跳计算";
if (jobType === "report") return "报告";
if (jobType === "scenario") return "加装避雷器复算";
return jobType || "-";
}
export function formatCurrentWaveform(value: string | null | undefined): string {
if (value === "heidler") return "Heidler";
if (value === "double_slope") return "双斜角";
if (value === "double_exponential") return "双指数";
return value || "-";
}
export function formatFlashoverMethod(value: string | null | undefined): string {
if (value === "guideline") return "规程法";
if (value === "intersection") return "相交法";
if (value === "leader_development") return "先导发展法";
return value || "-";
}
export function formatAltitudeCorrection(value: string | null | undefined): string {
if (value === "none") return "无";
if (value === "formula1") return "推荐公式1";
if (value === "formula2") return "推荐公式2";
return value || "-";
}
export function formatInducedVoltageFormula(value: string | null | undefined): string {
if (value === "formula1") return "公式1";
if (value === "formula2") return "公式2";
return value || "-";
}
export function formatExternalAdapter(value: string | null | undefined): string {
if (value === "wine") return "Wine / ATP";
if (value === "atp") return "原生 ATP";
if (value === "placeholder") return "规则近似";
if (value === "custom") return "自定义";
return value || "-";
}
export function riskColor(value: string | null | undefined): string {
if (value === "high") return "red";
if (value === "medium") return "orange";
if (value === "low") return "green";
return "default";
}
export function statusColor(value: string | null | undefined): string {
if (value === "success") return "green";
if (value === "failed" || value === "blocked") return "red";
if (value === "running") return "blue";
if (value === "queued") return "cyan";
return "default";
}
export function formatDateTime(value: string | null | undefined): string {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString("zh-CN", { hour12: false });
}
export function stringifyJson(value: unknown): string {
try {
return JSON.stringify(value ?? {}, null, 2);
} catch {
return "{}";
}
}
export function readObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function readString(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "-";
}
export function readOptionalString(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return null;
}
export function readOptionalNumber(record: Record<string, unknown>, key: string): number | null {
const value = record[key];
return typeof value === "number" ? value : null;
}
export function preparationColor(ready: boolean): string {
return ready ? "green" : "red";
}
export function readDownloadFilename(headerValue: string | null, fallback: string): string {
if (!headerValue) {
return fallback;
}
const utf8Matched = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
if (utf8Matched?.[1]) {
try {
return decodeURIComponent(utf8Matched[1]);
} catch {
return utf8Matched[1];
}
}
const matched = /filename="?([^"]+)"?/i.exec(headerValue);
return matched?.[1] ?? fallback;
}
export function readArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
export function readReasonDetails(row: FlAnalysisTowerResultSummary | null): ReasonDetail[] {
const value = row ? readObject(row.result_json).reason_details : null;
return readArray<ReasonDetail>(value);
}
export function readMitigationActions(row: FlAnalysisTowerResultSummary | null): MitigationAction[] {
const value = row ? readObject(row.result_json).mitigation_actions : null;
return readArray<MitigationAction>(value);
}
export function readWorkflow(row: FlAnalysisTowerResultSummary | null): WorkflowSummary {
return readObject(row ? readObject(row.result_json).workflow : null) as WorkflowSummary;
}
export function readSelectedCase(row: FlAnalysisTowerResultSummary | null): SelectedCase {
return readObject(row ? readObject(row.result_json).selected_case : null) as SelectedCase;
}
export function readScanPoints(row: FlAnalysisTowerResultSummary | null): ScanPoint[] {
return readArray<ScanPoint>(row ? readObject(row.result_json).scan_points : null);
}
export function readPhaseResults(row: FlAnalysisTowerResultSummary | null): PhaseResult[] {
return readArray<PhaseResult>(row ? readObject(row.result_json).phase_results : null);
}
export function readMultiPhaseResults(row: FlAnalysisTowerResultSummary | null): MultiPhaseResult[] {
return readArray<MultiPhaseResult>(row ? readObject(row.result_json).multi_phase_results : null);
}
export function readCurrentRisk(row: FlAnalysisTowerResultSummary): string | null {
const value = readObject(row.result_json).current_risk_level;
return typeof value === "string" ? value : null;
}
export function readCurrentScore(row: FlAnalysisTowerResultSummary): number | null {
const value = readObject(row.result_json).current_score;
return typeof value === "number" ? value : null;
}
export function mitigationMode(job: FlAnalysisJobDetail | FlAnalysisJobSummary | null): boolean {
if (!job) {
return false;
}
const options = readObject(job.execution_options_json);
return Boolean(options.non_construction);
}
export function waveformJobType(job: FlAnalysisJobDetail | FlAnalysisJobSummary | null): "normal" | "tongtiao" | null {
if (!job) {
return null;
}
if (job.job_type === "normal" || job.job_type === "tongtiao") {
return job.job_type;
}
if (job.job_type === "scenario") {
const baseJobType = readOptionalString(readObject(job.execution_options_json), "base_job_type");
return baseJobType === "tongtiao" ? "tongtiao" : "normal";
}
return null;
}
export function selectedTowerCount(job: FlAnalysisJobDetail | null): number {
if (!job) {
return 0;
}
const value = readObject(job.execution_options_json).selected_tower_ids;
return Array.isArray(value) ? value.length : 0;
}
export function formatRangeSummary(range: WorkflowRange | undefined): string {
if (!range) {
return "-";
}
const min = typeof range.min === "number" ? range.min : null;
const max = typeof range.max === "number" ? range.max : null;
const step = typeof range.step === "number" ? range.step : null;
if (min === null || max === null || step === null) {
return "-";
}
return `${min} ~ ${max} / 步长 ${step}`;
}