Files
next-tjwater-frontend/src/features/workbench/components/feature-location-panel.tsx
T

103 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Crosshair, LocateFixed, MousePointer2, Trash2, ZoomIn } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/shared/ui/button";
import { FieldSelect } from "@/shared/ui/field-select";
import { Textarea } from "@/shared/ui/textarea";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, SUPPLY_LAYER_CATALOG, type AvailableWaterNetworkSourceId } from "../map/sources";
import type { FeatureBatchTarget, WorkbenchMapCommands, WorkbenchMapControllerState } from "../map/workbench-map-controller";
import type { DetailFeature } from "../types";
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
type FeatureLocationDraft = {
sourceId: AvailableWaterNetworkSourceId;
featureId: string;
};
export type FeatureLocationPanelProps = {
commands: Pick<WorkbenchMapCommands, "highlight" | "zoomToFeature" | "locateAndHighlight" | "clearHighlight">;
controllerState: Pick<WorkbenchMapControllerState, "pending" | "targets" | "targetResult" | "errorCode">;
detailFeature: DetailFeature | null;
enabled?: boolean;
value?: FeatureLocationDraft;
onValueChange?: (value: FeatureLocationDraft) => void;
onUseSelectedFeature?: () => void;
showError?: boolean;
contentClassName?: string;
};
export function FeatureLocationPanel({ commands, controllerState, detailFeature, enabled = true,
value, onValueChange, onUseSelectedFeature, showError = true, contentClassName }: FeatureLocationPanelProps) {
const inputId = useId();
const helpId = useId();
const [draft, setDraft] = useState<FeatureLocationDraft>({ sourceId: "pipes", featureId: "" });
const { sourceId, featureId } = value ?? draft;
function updateDraft(next: FeatureLocationDraft) {
if (!value) setDraft(next);
onValueChange?.(next);
}
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = enabled && featureIds.length > 0 && !invalidIds && !controllerState.pending;
const canUseSelected = Boolean(detailFeature && AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId));
const target: FeatureBatchTarget = { sourceId, featureIds };
const result = controllerState.targetResult;
return (
<div className="space-y-3">
<FieldSelect label="业务源" value={sourceId} contentClassName={contentClassName}
onValueChange={(nextSourceId) => updateDraft({ sourceId: nextSourceId as AvailableWaterNetworkSourceId, featureId: "" })}
options={SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => ({ value: layer.id, label: layer.label }))}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label htmlFor={inputId} className="text-xs font-medium text-[var(--ink-muted)]">要素 ID(可多个)</label>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{featureIds.length} / {MAX_MAP_FEATURE_IDS}</span>
</div>
<Textarea id={inputId} value={featureId} rows={3} placeholder="粘贴要素 ID,支持多个"
onChange={(event) => updateDraft({ sourceId, featureId: event.target.value })} aria-describedby={helpId} aria-invalid={invalidIds}
className="min-h-24 resize-y border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
/>
<p id={helpId} className="text-[11px] leading-5 text-[var(--ink-muted)]">逗号、空格或换行分隔,自动去重。</p>
</div>
{invalidIds ? <p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
<Button type="button" variant="secondary" className="w-full text-xs" disabled={!canUseSelected}
onClick={() => {
if (!detailFeature || !canUseSelected) return;
updateDraft({ sourceId: detailFeature.layer as AvailableWaterNetworkSourceId, featureId: detailFeature.id });
onUseSelectedFeature?.();
}}>
<MousePointer2 aria-hidden="true" />使用当前地图选中
</Button>
<div className="space-y-2 border-t border-[var(--surface-divider)] pt-3">
<Button type="button" className="w-full text-xs" disabled={!canLocate}
loading={controllerState.pending} loadingLabel="正在定位要素"
onClick={() => void commands.locateAndHighlight(target)}>
<LocateFixed aria-hidden="true" />定位并高亮
</Button>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.highlight(target)}>
<Crosshair aria-hidden="true" />仅高亮
</Button>
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.zoomToFeature(target)}>
<ZoomIn aria-hidden="true" />缩放到要素
</Button>
</div>
</div>
{!enabled ? <p role="status" className="text-xs text-[var(--ink-muted)]">等待地图加载后即可定位</p> : null}
{showError && controllerState.errorCode && FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode] ? (
<p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">{FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode]}</p>
) : null}
{result ? <p role="status" className="break-words text-xs leading-5 text-[var(--ink-muted)]">
本次命中 {result.featureIds.length} 个{result.missingIds.length ? `,未找到 ${result.missingIds.length} 个:${result.missingIds.join("、")}` : ",全部找到"}
</p> : null}
<div className="flex items-center justify-between gap-2 border-t border-[var(--surface-divider)] pt-2">
<p className="text-[11px] text-[var(--ink-muted)]">已高亮 {controllerState.targets.length} 个要素</p>
<Button type="button" variant="ghost" className="shrink-0 text-xs" onClick={() => commands.clearHighlight()}>
<Trash2 aria-hidden="true" />清除高亮
</Button>
</div>
</div>
);
}