103 lines
6.3 KiB
TypeScript
103 lines
6.3 KiB
TypeScript
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>
|
||
);
|
||
}
|