feat: extend map targeting and overlay controls
Support batch feature targeting and clear map highlights and overlays from the More panel. Remove misleading action chevrons and unused configuration export entries. Validated the isolated patch with lint, typecheck, 36 focused tests and 5 browser cases using mocked data.
This commit is contained in:
@@ -10,6 +10,28 @@ import { MapDevPanel } from "./map-dev-panel";
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("MapDevPanel", () => {
|
||||
it("deduplicates pasted IDs for every location action and prevents oversized batches", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
|
||||
detailFeature={null} onClose={vi.fn()} />);
|
||||
const input = screen.getByLabelText("要素 ID(可多个)");
|
||||
await user.clear(input);
|
||||
await user.paste("P-1,P-2\nP-1 P-3;P-4");
|
||||
const target = { sourceId: "pipes", featureIds: ["P-1", "P-2", "P-3", "P-4"] };
|
||||
await user.click(screen.getByRole("button", { name: "仅高亮" }));
|
||||
expect(commands.highlight).toHaveBeenCalledWith(target);
|
||||
await user.click(screen.getByRole("button", { name: "缩放到要素" }));
|
||||
expect(commands.zoomToFeature).toHaveBeenCalledWith(target);
|
||||
await user.click(screen.getByRole("button", { name: "定位并高亮" }));
|
||||
expect(commands.locateAndHighlight).toHaveBeenCalledWith(target);
|
||||
await user.clear(input);
|
||||
expect(screen.getByRole("button", { name: "定位并高亮" })).toBeDisabled();
|
||||
await user.paste(Array.from({ length: 101 }, (_, i) => `P-${i}`).join(","));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("单次最多 100 个");
|
||||
expect(screen.getByRole("button", { name: "定位并高亮" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("defaults to following investigations and allows disabling it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFollowChange = vi.fn();
|
||||
@@ -109,6 +131,8 @@ function createControllerState(
|
||||
): WorkbenchMapControllerState {
|
||||
return {
|
||||
target: null,
|
||||
targets: [],
|
||||
targetResult: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
|
||||
@@ -5,15 +5,17 @@ import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "../map/layer-group-style";
|
||||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureTarget,
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
@@ -90,6 +92,7 @@ const LABEL_PRESETS: Record<
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
|
||||
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
|
||||
@@ -140,7 +143,10 @@ export function MapDevPanel({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
|
||||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||||
const target: FeatureBatchTarget = { sourceId, featureIds };
|
||||
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
|
||||
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
|
||||
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
|
||||
const activeLabel = controllerState.valueLabels[groupId];
|
||||
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
|
||||
@@ -286,13 +292,13 @@ export function MapDevPanel({
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="读取目标要素后,执行地图高亮或相机定位。"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.target ? "已高亮" : undefined
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2">
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
@@ -305,13 +311,21 @@ export function MapDevPanel({
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<Field label="Feature ID">
|
||||
<Input
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
className={inputClassName}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -325,7 +339,7 @@ export function MapDevPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
@@ -334,17 +348,17 @@ export function MapDevPanel({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到 Feature
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
@@ -359,9 +373,15 @@ export function MapDevPanel({
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.target ? (
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
@@ -423,8 +443,8 @@ export function MapDevPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || !target.featureId}
|
||||
onClick={() => setFlowPipeId(target.featureId)}
|
||||
disabled={sourceId !== "pipes" || featureIds.length !== 1}
|
||||
onClick={() => setFlowPipeId(featureIds[0])}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -803,11 +823,11 @@ function RangeField({
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ children }: { children: React.ReactNode }) {
|
||||
function StatusText({ children, tone = "success" }: { children: React.ReactNode; tone?: "success" | "warning" }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="status-tone-success break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]"
|
||||
className={`status-tone-${tone} break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Copy,
|
||||
Database,
|
||||
Dot,
|
||||
Download,
|
||||
Eraser,
|
||||
Keyboard,
|
||||
Layers3,
|
||||
MapPinned,
|
||||
@@ -134,7 +134,7 @@ export type ToolbarPanelProps = {
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onClearMapOverlays: () => void;
|
||||
panelWidthClassName?: string;
|
||||
};
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ToolbarPanel({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
onClearMapOverlays,
|
||||
panelWidthClassName
|
||||
}: ToolbarPanelProps) {
|
||||
const annotationItems = useMemo<MapAnnotationItem[]>(
|
||||
@@ -403,7 +403,7 @@ export function ToolbarPanel({
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onClearMapOverlays={onClearMapOverlays}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
@@ -476,22 +476,25 @@ function LegendPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
|
||||
}
|
||||
|
||||
type MorePanelProps = {
|
||||
onClearMapOverlays: () => void;
|
||||
onExportView: (preset: ExportViewPreset) => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
};
|
||||
|
||||
function MorePanel({
|
||||
onClearMapOverlays,
|
||||
onExportView,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onShowShortcuts
|
||||
}: MorePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="清理地图">
|
||||
<MapActionRow icon={Eraser} label="清空所有高亮和覆盖层" description="分析、测量、标注与定位标记" onClick={onClearMapOverlays} />
|
||||
</MapPanelSection>
|
||||
<MapPanelSection title="导出视图">
|
||||
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
|
||||
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
|
||||
@@ -504,7 +507,6 @@ function MorePanel({
|
||||
|
||||
<MapPanelSection title="系统">
|
||||
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
|
||||
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
Download,
|
||||
FileText,
|
||||
Keyboard,
|
||||
LogOut,
|
||||
@@ -282,7 +281,6 @@ export function UserMenu({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -292,7 +290,6 @@ export function UserMenu({
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||
@@ -335,7 +332,6 @@ export function UserMenu({
|
||||
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<MenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -59,7 +59,6 @@ export type WorkbenchTopBarProps = {
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -97,7 +96,6 @@ export function WorkbenchTopBar({
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
@@ -256,7 +254,6 @@ export function WorkbenchTopBar({
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user