董国庆
2026-05-12 6bc97c628707030b19c3bf61d8e673b6143a0473
feat(operationWorkbench): 优化围栏编辑模块的统计展示与业务逻辑

新增场站状态统计工具函数,统一处理场站状态归一化与统计逻辑
重构MapInfoPopupCard弹窗组件,支持通用化的概览和明细卡片配置
将原有的超出范围点位弹窗替换为围栏影响统计弹窗,展示点位与场站的总览、新增/移出统计
修改FencePanel中的“测试站”文案为“待测试”,统一状态表述
新增6张地图相关静态资源图片与1个切换图标SVG
重构围栏编辑的校验与警告样式逻辑,移除冗余的场站超限提示
优化切换tab时的选中场站逻辑,自动匹配当前tab的首个场站
调整围栏保存时的确认提示文案,展示更详细的变更信息
7个文件已添加
4个文件已修改
1125 ■■■■■ 已修改文件
src/assets/map-info/img1.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/img2.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/img3.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/img4.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/img5.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/img6.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/toggle-icon.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/components/FencePanel.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/components/MapInfoPopupCard.vue 845 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/map.vue 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/status.ts 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/map-info/img1.png
src/assets/map-info/img2.png
src/assets/map-info/img3.png
src/assets/map-info/img4.png
src/assets/map-info/img5.png
src/assets/map-info/img6.png
src/assets/map-info/toggle-icon.svg
New file
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="10.5" height="6" viewBox="0 0 10.5 6"><path d="M4.7109375,0.2109375Q4.9453125,0,5.25,0Q5.5546875,0,5.7890625,0.2109375L10.289062,4.7109375Q10.5,4.9453125,10.5,5.25Q10.5,5.5546875,10.289062,5.7890625Q10.054688,6,9.75,6Q9.4453125,6,9.2109375,5.7890625L5.25,1.8046875L1.2890625,5.7890625Q1.0546875,6,0.75,6Q0.4453125,6,0.2109375,5.7890625Q0,5.5546875,0,5.25Q0,4.9453125,0.2109375,4.7109375L4.7109375,0.2109375Z" fill="#666666" fill-opacity="1" style="mix-blend-mode:passthrough"/></svg>
src/views/businessOperations/operationWorkbench/components/FencePanel.vue
@@ -112,7 +112,7 @@
      { label: "待生效", value: 33, color: "warning" },
      { label: "已停用", value: 12, color: "muted" },
      { label: "已失效", value: 8, color: "muted" },
      { label: "测试站", value: 5, color: "info" },
      { label: "待测试", value: 5, color: "info" },
      { label: "测试中", value: 3, color: "info" },
      { label: "测试不通过", value: 2, color: "muted" },
    ],
src/views/businessOperations/operationWorkbench/components/MapInfoPopupCard.vue
@@ -1,251 +1,245 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref } from "vue";
import img1 from "@/assets/map-info/img1.png";
import img2 from "@/assets/map-info/img2.png";
import img3 from "@/assets/map-info/img3.png";
import img4 from "@/assets/map-info/img4.png";
import img5 from "@/assets/map-info/img5.png";
import img6 from "@/assets/map-info/img6.png";
import toggleIcon from "@/assets/map-info/toggle-icon.svg";
interface SummaryItem {
  label: string;
  value: string | number;
interface OverviewCardInput {
  key: string;
  title: string;
  total?: number;
  available?: number;
  unavailable?: number;
}
interface DetailCardInput {
  key: string;
  title: string;
  subtitle: string;
  pointCount?: number;
  stationCount?: number;
  total?: number;
}
const props = withDefaults(
  defineProps<{
    title?: string;
    total?: string | number;
    defaultExpanded?: boolean;
    availableTitle?: string;
    availableItems?: SummaryItem[];
    unavailableTitle?: string;
    unavailableItems?: SummaryItem[];
    overviewCards?: OverviewCardInput[];
    detailCards?: DetailCardInput[];
  }>(),
  {
    title: "超出范围点位",
    total: "8888",
    defaultExpanded: true,
    availableTitle: "可用点位",
    availableItems: () => [
      { label: "可运营", value: "888" },
      { label: "待生效", value: "888" },
      { label: "启用中", value: "888" },
    ],
    unavailableTitle: "不可用点位",
    unavailableItems: () => [
      { label: "待测试", value: "888" },
      { label: "测试中", value: "888" },
      { label: "测试不通过", value: "888" },
      { label: "已停用", value: "888" },
      { label: "失效", value: "888" },
    ],
    title: "围栏影响统计",
    overviewCards: () => [],
    detailCards: () => [],
  }
);
const isExpanded = ref(props.defaultExpanded);
interface OverviewCard extends OverviewCardInput {
  total: number;
  totalColor: string;
  available: number;
  unavailable: number;
  iconSrc: string;
}
type TransitionElement = HTMLElement;
interface DetailCard extends DetailCardInput {
  pointCount: number;
  stationCount: number;
  total: number;
  totalColor: string;
  iconSrc: string;
  summary: string;
}
const defaultOverviewCards: OverviewCardInput[] = [
  {
    key: "point",
    title: "点位",
    total: 20,
    available: 12,
    unavailable: 8,
  },
  {
    key: "station",
    title: "场站",
    total: 9,
    available: 6,
    unavailable: 3,
  },
];
const defaultDetailCards: DetailCardInput[] = [
  {
    key: "remove",
    title: "移出围栏",
    subtitle: "不在圈内",
    pointCount: 3,
    stationCount: 1,
    total: 4,
  },
  {
    key: "add",
    title: "新增包含",
    subtitle: "进入圈内",
    pointCount: 5,
    stationCount: 2,
    total: 7,
  },
];
const overviewCards = computed<OverviewCard[]>(() => {
  const source = props.overviewCards.length
    ? props.overviewCards
    : defaultOverviewCards;
  return source.map((item) => ({
    ...item,
    total: Number(item.total || 0),
    available: Number(item.available || 0),
    unavailable: Number(item.unavailable || 0),
    totalColor: item.key === "station" ? "#FA8C16" : "#1677FF",
    iconSrc: item.key === "station" ? img3 : img2,
  }));
});
const detailCards = computed<DetailCard[]>(() => {
  const source = props.detailCards.length ? props.detailCards : defaultDetailCards;
  return source.map((item) => {
    const pointCount = Number(item.pointCount || 0);
    const stationCount = Number(item.stationCount || 0);
    return {
      ...item,
      pointCount,
      stationCount,
      total: Number(item.total ?? pointCount + stationCount),
      totalColor: item.key === "add" ? "#52C41A" : "#FF4D4F",
      iconSrc: item.key === "add" ? img6 : img5,
      summary: `点位${pointCount}・场站${stationCount}`,
    };
  });
});
const isExpanded = ref(true);
function toggleExpanded() {
  isExpanded.value = !isExpanded.value;
}
function finishHeightTransition(el: TransitionElement, done?: () => void) {
  const onTransitionEnd = (event: TransitionEvent) => {
    if (event.target !== el || event.propertyName !== "height") return;
    el.removeEventListener("transitionend", onTransitionEnd);
    done?.();
  };
  el.addEventListener("transitionend", onTransitionEnd);
}
function onBeforeEnter(el: Element) {
  const target = el as TransitionElement;
  target.style.height = "0";
  target.style.opacity = "0";
  target.style.transform = "translateY(-10px)";
  target.style.overflow = "hidden";
}
function onEnter(el: Element, done: () => void) {
  const target = el as TransitionElement;
  void target.offsetHeight;
  target.style.transition =
    "height 0.42s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.28s ease, transform 0.42s cubic-bezier(0.23, 1, 0.32, 1)";
  target.style.height = `${target.scrollHeight}px`;
  target.style.opacity = "1";
  target.style.transform = "translateY(0)";
  finishHeightTransition(target, done);
}
function onAfterEnter(el: Element) {
  const target = el as TransitionElement;
  target.style.height = "auto";
  target.style.overflow = "";
  target.style.transition = "";
  target.style.opacity = "";
  target.style.transform = "";
}
function onBeforeLeave(el: Element) {
  const target = el as TransitionElement;
  target.style.height = `${target.scrollHeight}px`;
  target.style.opacity = "1";
  target.style.transform = "translateY(0)";
  target.style.overflow = "hidden";
}
function onLeave(el: Element, done: () => void) {
  const target = el as TransitionElement;
  void target.offsetHeight;
  target.style.transition =
    "height 0.38s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease, transform 0.32s ease";
  target.style.height = "0";
  target.style.opacity = "0";
  target.style.transform = "translateY(-8px)";
  finishHeightTransition(target, done);
}
function onAfterLeave(el: Element) {
  const target = el as TransitionElement;
  target.style.transition = "";
  target.style.height = "";
  target.style.opacity = "";
  target.style.transform = "";
  target.style.overflow = "";
}
</script>
<template>
  <div class="map-info-popup-card">
    <div class="map-info-popup-card__shell">
      <button
        type="button"
        class="map-info-popup-card__header"
        @click="toggleExpanded"
      >
        <div class="map-info-popup-card__header-main">
          <div class="map-info-popup-card__title-bar" />
          <h3 class="map-info-popup-card__title">{{ title }}</h3>
      <div class="map-info-popup-card__header">
        <div class="map-info-popup-card__title">
          {{ props.title }}
        </div>
        <div class="map-info-popup-card__header-side">
          <span class="map-info-popup-card__total">{{ total }}</span>
          <span class="map-info-popup-card__toggle" :class="{ expanded: isExpanded }">
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <path
                d="M7 10l5 5 5-5"
                fill="none"
                stroke="currentColor"
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2.2"
              />
            </svg>
          </span>
        </div>
      </button>
        <span
          class="map-info-popup-card__toggle"
          :class="{ 'is-expanded': isExpanded }"
          aria-hidden="true"
          @click="toggleExpanded"
        >
          <img class="map-info-popup-card__toggle-icon" :src="toggleIcon" alt="" />
        </span>
      </div>
      <Transition
        @before-enter="onBeforeEnter"
        @enter="onEnter"
        @after-enter="onAfterEnter"
        @before-leave="onBeforeLeave"
        @leave="onLeave"
        @after-leave="onAfterLeave"
      >
        <div v-if="isExpanded" class="map-info-popup-card__content-wrap">
          <div class="map-info-popup-card__content">
            <section
              class="map-info-popup-card__section map-info-popup-card__section--available"
            >
              <div class="map-info-popup-card__section-bg">
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path
                    d="M20 6L9 17l-5-5"
                    fill="none"
                    stroke="currentColor"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="3"
                  />
                </svg>
              </div>
              <div class="map-info-popup-card__section-head">
                <div class="map-info-popup-card__section-icon">
                  <svg viewBox="0 0 24 24" aria-hidden="true">
                    <path
                      d="M20 6L9 17l-5-5"
                      fill="none"
                      stroke="currentColor"
                      stroke-linecap="round"
                      stroke-linejoin="round"
                      stroke-width="3"
                    />
                  </svg>
                </div>
                <span class="map-info-popup-card__section-title">
                  {{ availableTitle }}
                </span>
              </div>
              <div class="map-info-popup-card__grid map-info-popup-card__grid--three">
                <div
                  v-for="item in availableItems"
                  :key="item.label"
                  class="map-info-popup-card__metric"
                >
                  <span class="map-info-popup-card__metric-label">
                    {{ item.label }}
                  </span>
                  <span class="map-info-popup-card__metric-value">
                    {{ item.value }}
                  </span>
                </div>
              </div>
            </section>
      <div v-show="isExpanded" class="map-info-popup-card__content">
        <div class="map-info-popup-card__divider" />
            <section
              class="map-info-popup-card__section map-info-popup-card__section--unavailable"
            >
              <div class="map-info-popup-card__section-bg">
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path
                    d="M6 6l12 12M18 6L6 18"
                    fill="none"
                    stroke="currentColor"
                    stroke-linecap="round"
                    stroke-width="3"
                  />
                </svg>
              </div>
              <div class="map-info-popup-card__section-head">
                <div class="map-info-popup-card__section-icon">
                  <svg viewBox="0 0 24 24" aria-hidden="true">
                    <path
                      d="M6 6l12 12M18 6L6 18"
                      fill="none"
                      stroke="currentColor"
                      stroke-linecap="round"
                      stroke-width="3"
                    />
                  </svg>
                </div>
                <span class="map-info-popup-card__section-title">
                  {{ unavailableTitle }}
                </span>
              </div>
              <div class="map-info-popup-card__grid map-info-popup-card__grid--mixed">
                <div
                  v-for="item in unavailableItems"
                  :key="item.label"
                  class="map-info-popup-card__metric"
                >
                  <span class="map-info-popup-card__metric-label">
                    {{ item.label }}
                  </span>
                  <span class="map-info-popup-card__metric-value">
                    {{ item.value }}
                  </span>
                </div>
              </div>
            </section>
        <section class="map-info-popup-card__block">
          <div class="map-info-popup-card__block-title">
            <img class="map-info-popup-card__block-icon" :src="img1" alt="" />
            <span>实时概览</span>
          </div>
        </div>
      </Transition>
          <div class="map-info-popup-card__overview">
            <div
              v-for="card in overviewCards"
              :key="card.key"
              class="map-info-popup-card__overview-card"
            >
              <div class="map-info-popup-card__overview-head">
                <img
                  class="map-info-popup-card__entity-icon"
                  :src="card.iconSrc"
                  alt=""
                />
                <span class="map-info-popup-card__entity-title">
                  {{ card.title }}
                </span>
                <span
                  class="map-info-popup-card__entity-total"
                  :style="{ color: card.totalColor }"
                >
                  {{ card.total }}
                </span>
              </div>
              <div class="map-info-popup-card__overview-body">
                <div class="map-info-popup-card__metric-column">
                  <span class="map-info-popup-card__metric-label">可用</span>
                  <span class="map-info-popup-card__metric-number is-green">
                    {{ card.available }}
                  </span>
                </div>
                <div class="map-info-popup-card__metric-column">
                  <span class="map-info-popup-card__metric-label">不可用</span>
                  <span class="map-info-popup-card__metric-number is-gray">
                    {{ card.unavailable }}
                  </span>
                </div>
              </div>
            </div>
          </div>
        </section>
        <div class="map-info-popup-card__divider" />
        <section class="map-info-popup-card__block">
          <div class="map-info-popup-card__block-title">
            <img class="map-info-popup-card__block-icon" :src="img4" alt="" />
            <span>变动明细</span>
          </div>
          <div class="map-info-popup-card__detail-list">
            <div
              v-for="card in detailCards"
              :key="card.key"
              class="map-info-popup-card__detail-card"
            >
              <img
                class="map-info-popup-card__detail-icon"
                :src="card.iconSrc"
                alt=""
              />
              <div class="map-info-popup-card__detail-main">
                <div class="map-info-popup-card__detail-title">
                  {{ card.title }}
                </div>
                <div class="map-info-popup-card__detail-subtitle">
                  {{ card.subtitle }}
                </div>
              </div>
              <div class="map-info-popup-card__detail-side">
                <div class="map-info-popup-card__detail-summary">
                  {{ card.summary }}
                </div>
                <div
                  class="map-info-popup-card__detail-total"
                  :style="{ color: card.totalColor }"
                >
                  {{ card.total }}
                </div>
              </div>
            </div>
          </div>
        </section>
      </div>
    </div>
  </div>
</template>
@@ -254,270 +248,241 @@
.map-info-popup-card {
  position: absolute;
  z-index: 998;
  width: 360px;
  width: 280px;
  max-width: calc(100% - 48px);
  animation: map-info-popup-card-enter 0.45s ease;
}
.map-info-popup-card__shell {
  overflow: hidden;
  border: 1px solid rgba(255, 255, 255, 0.4);
  border-radius: 28px;
  background: rgba(255, 255, 255, 0.72);
  backdrop-filter: blur(24px);
  box-shadow: 0 32px 64px -16px rgba(0, 0, 0, 0.12);
  padding: 12px;
  border-radius: 8px;
  background: #ffffff;
  box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.1);
}
.map-info-popup-card__header {
  width: 100%;
  padding: 16px 24px;
  border: none;
  background: transparent;
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: pointer;
  text-align: left;
}
.map-info-popup-card__header-main {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}
.map-info-popup-card__title-bar {
  width: 4px;
  height: 24px;
  border-radius: 999px;
  background: linear-gradient(180deg, #3b82f6 0%, #4f46e5 100%);
  box-shadow: 0 0 10px rgba(59, 130, 246, 0.4);
  flex-shrink: 0;
}
.map-info-popup-card__title {
  margin: 0;
  color: #0f172a;
  font-size: 18px;
  font-family: Source Han Sans;
  font-size: 13px;
  font-weight: 900;
  line-height: 1.2;
  letter-spacing: -0.02em;
}
.map-info-popup-card__header-side {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-shrink: 0;
}
.map-info-popup-card__total {
  color: #2563eb;
  font-size: 24px;
  font-weight: 900;
  line-height: 1;
  font-family:
    "SFMono-Regular",
    "Consolas",
    "Liberation Mono",
    monospace;
  text-shadow: 0 1px 1px rgba(37, 99, 235, 0.12);
  line-height: normal;
  letter-spacing: normal;
  color: #1a1a1a;
}
.map-info-popup-card__toggle {
  width: 28px;
  height: 28px;
  border-radius: 999px;
  background: rgba(241, 245, 249, 0.85);
  color: #94a3b8;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition:
    background-color 0.2s ease,
    color 0.2s ease,
    transform 0.2s ease;
  svg {
    width: 16px;
    height: 16px;
    transition: transform 0.2s ease;
  }
  &.expanded svg {
    transform: rotate(180deg);
  }
}
.map-info-popup-card__header:hover .map-info-popup-card__toggle {
  background: rgba(239, 246, 255, 0.95);
  color: #2563eb;
}
.map-info-popup-card__content {
  padding: 0 20px 24px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  will-change: transform, opacity;
}
.map-info-popup-card__section {
  position: relative;
  overflow: hidden;
  padding: 16px;
  border-radius: 24px;
  border: 1px solid rgba(0, 0, 0, 0.04);
}
.map-info-popup-card__section-bg {
  position: absolute;
  top: 0;
  right: 0;
  padding: 12px;
  opacity: 0.08;
  pointer-events: none;
  svg {
    width: 40px;
    height: 40px;
  }
}
.map-info-popup-card__section--available {
  background: linear-gradient(135deg, rgba(236, 253, 245, 0.9) 0%, rgba(240, 253, 250, 0.7) 100%);
  border-color: rgba(167, 243, 208, 0.7);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
  .map-info-popup-card__section-bg,
  .map-info-popup-card__metric-label {
    color: rgba(5, 150, 105, 0.6);
  }
  .map-info-popup-card__section-icon {
    background: #10b981;
    box-shadow: 0 10px 20px rgba(16, 185, 129, 0.2);
  }
  .map-info-popup-card__section-title {
    color: #047857;
  }
  .map-info-popup-card__metric-value {
    color: #064e3b;
  }
}
.map-info-popup-card__section--unavailable {
  background: linear-gradient(135deg, rgba(248, 250, 252, 0.95) 0%, rgba(241, 245, 249, 0.75) 100%);
  border-color: rgba(226, 232, 240, 0.9);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
  .map-info-popup-card__section-bg,
  .map-info-popup-card__metric-label {
    color: #94a3b8;
  }
  .map-info-popup-card__section-icon {
    background: #94a3b8;
    box-shadow: 0 10px 20px rgba(148, 163, 184, 0.18);
  }
  .map-info-popup-card__section-title {
    color: #64748b;
  }
  .map-info-popup-card__metric-value {
    color: #1e293b;
  }
}
.map-info-popup-card__section-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
}
.map-info-popup-card__section-icon {
  width: 24px;
  height: 24px;
  border-radius: 8px;
  color: #fff;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  cursor: pointer;
  svg {
    width: 12px;
    height: 12px;
  &.is-expanded {
    .map-info-popup-card__toggle-icon {
      transform: rotate(180deg);
    }
  }
}
.map-info-popup-card__section-title {
  font-size: 13px;
  font-weight: 900;
  line-height: 1;
  letter-spacing: 0.14em;
  text-transform: uppercase;
.map-info-popup-card__toggle-icon {
  width: 10.5px;
  height: 6px;
  object-fit: contain;
  transition: transform 0.2s ease;
}
.map-info-popup-card__grid {
  display: grid;
.map-info-popup-card__divider {
  height: 1px;
  margin: 10px 0;
  background: #e8e8e8;
  &:last-child {
    margin: 12px 0;
  }
}
.map-info-popup-card__block {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.map-info-popup-card__grid--three {
  grid-template-columns: repeat(3, minmax(0, 1fr));
.map-info-popup-card__block-title {
  display: flex;
  align-items: center;
  gap: 4px;
  font-family: Source Han Sans;
  font-size: 12px;
  font-weight: 900;
  line-height: normal;
  letter-spacing: normal;
  color: #333333;
}
.map-info-popup-card__grid--mixed {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  row-gap: 12px;
.map-info-popup-card__block-icon {
  width: 12px;
  height: 11px;
  flex-shrink: 0;
  margin: 3px 0;
  object-fit: contain;
}
.map-info-popup-card__metric {
  min-width: 0;
.map-info-popup-card__overview {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
}
.map-info-popup-card__overview-card {
  padding: 8px;
  border-radius: 4px;
  background: #f5f5f5;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.map-info-popup-card__overview-head {
  display: flex;
  align-items: center;
  gap: 4px;
}
.map-info-popup-card__entity-icon {
  width: 8px;
  height: 11px;
  object-fit: contain;
  flex-shrink: 0;
}
.map-info-popup-card__entity-title {
  flex: 1;
  font-family: Source Han Sans;
  font-size: 11px;
  font-weight: 500;
  line-height: normal;
  letter-spacing: normal;
  color: #595959;
}
.map-info-popup-card__entity-total {
  font-family: Source Han Sans;
  font-size: 16px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
  color: #1890ff;
}
.map-info-popup-card__overview-body {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.map-info-popup-card__metric-column {
  // flex:1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.map-info-popup-card__metric-label {
  font-size: 9px;
  font-weight: 900;
  line-height: 1.25;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-family: Source Han Sans;
  font-size: 10px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
  color: #999999;
}
.map-info-popup-card__metric-value {
  font-size: 15px;
  font-weight: 900;
  line-height: 1.2;
  font-family:
    "SFMono-Regular",
    "Consolas",
    "Liberation Mono",
    monospace;
}
.map-info-popup-card__metric-number {
  font-family: Source Han Sans;
  font-size: 13px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
.map-info-popup-card__content-wrap {
  transform-origin: top center;
  will-change: height, opacity, transform;
}
@keyframes map-info-popup-card-enter {
  from {
    opacity: 0;
    transform: translateY(20px);
  &.is-green {
    color: #52c41a;
  }
  to {
    opacity: 1;
    transform: translateY(0);
  &.is-gray {
    color: rgba(23, 31, 38, 0.45);
  }
}
.map-info-popup-card__detail-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.map-info-popup-card__detail-card {
  padding: 8px;
  border-radius: 4px;
  background: #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.map-info-popup-card__detail-icon {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  object-fit: contain;
}
.map-info-popup-card__detail-main {
  flex: 1;
  min-width: 0;
}
.map-info-popup-card__detail-title {
  font-family: Source Han Sans;
  font-size: 11px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
  color: #595959;
}
.map-info-popup-card__detail-subtitle {
  font-family: Source Han Sans;
  font-size: 10px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
  color: #999999;
}
.map-info-popup-card__detail-side {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}
.map-info-popup-card__detail-summary {
  font-family: Source Han Sans;
  font-size: 10px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
  color: #666666;
}
.map-info-popup-card__detail-total {
  font-family: Source Han Sans;
  font-size: 16px;
  font-weight: normal;
  line-height: normal;
  letter-spacing: normal;
}
</style>
src/views/businessOperations/operationWorkbench/map.vue
@@ -133,6 +133,7 @@
} from "./workbench.station-workflow";
import {
  buildPointStatusMetricSummary,
  buildStationStatusMetricSummary,
  getPointStatusText,
  getStatusClass,
} from "./status";
@@ -847,6 +848,91 @@
const fenceInnerPointSummary = computed(() =>
  buildPointStatusMetricSummary(fenceInnerPointList.value)
);
const fenceOriginalPointList = computed(() => {
  if (fenceCreateMode.value) return [];
  const originalOperationPoints = getFenceOriginalOperationPoints();
  if (originalOperationPoints.length < 3) return [];
  return getPointItemsWithinPolygon(originalOperationPoints, pointSpatialData.value);
});
const fenceInnerStationList = computed(() => {
  if (!showFenceEditMapInfoPopup.value || fenceDraftPoints.value.length < 3) return [];
  return stationData.value
    .filter(
      (item) => Array.isArray(item.polygonPath) && item.polygonPath.length >= 3
    )
    .filter((item) => isRingInRing(item.polygonPath || [], fenceDraftPoints.value));
});
const fenceOriginalStationList = computed(() => {
  if (fenceCreateMode.value) return [];
  return getFenceAffectedStationsByOriginalArea();
});
const fenceInnerStationSummary = computed(() =>
  buildStationStatusMetricSummary(
    fenceInnerStationList.value.map((item) => ({
      status: item.status,
      areaStatus: item.raw?.areaStatus,
    }))
  )
);
function buildFenceImpactDelta(
  currentList: Array<{ id?: string | number }>,
  originalList: Array<{ id?: string | number }>
) {
  const currentIdSet = new Set(currentList.map((item) => String(item.id || "")));
  const originalIdSet = new Set(
    originalList.map((item) => String(item.id || ""))
  );
  const removedCount = originalList.filter(
    (item) => !currentIdSet.has(String(item.id || ""))
  ).length;
  const addedCount = currentList.filter(
    (item) => !originalIdSet.has(String(item.id || ""))
  ).length;
  return {
    removedCount,
    addedCount,
  };
}
const fenceImpactDeltaSummary = computed(() => {
  const pointDelta = buildFenceImpactDelta(
    fenceInnerPointList.value,
    fenceOriginalPointList.value
  );
  const stationDelta = buildFenceImpactDelta(
    fenceInnerStationList.value,
    fenceOriginalStationList.value
  );
  return {
    remove: {
      pointCount: pointDelta.removedCount,
      stationCount: stationDelta.removedCount,
      total: pointDelta.removedCount + stationDelta.removedCount,
    },
    add: {
      pointCount: pointDelta.addedCount,
      stationCount: stationDelta.addedCount,
      total: pointDelta.addedCount + stationDelta.addedCount,
    },
  };
});
function buildFenceSubmitConfirmMessage(disabledPointCount = 0) {
  const pointCount = fenceImpactDeltaSummary.value.remove.pointCount;
  const stationCount = fenceImpactDeltaSummary.value.remove.stationCount;
  const segments: string[] = [];
  if (pointCount && stationCount)
    segments.push(`将移出${pointCount}个点位、${stationCount}个场站`);
  else if (pointCount) segments.push(`将移出${pointCount}个点位`);
  else if (stationCount) segments.push(`将移出${stationCount}个场站`);
  if (disabledPointCount > 0)
    segments.push(`其中${disabledPointCount}个点位会被停用`);
  if (!segments.length) return "";
  return `确认提交吗?本次修改${segments.join(",")}。`;
}
const fenceOutOfRangePointList = computed(() => {
  if (!showFenceEditMapInfoPopup.value || fenceDraftPoints.value.length < 3) return [];
  const innerPointIdSet = new Set(
@@ -1023,8 +1109,6 @@
  if (!isFenceFormMode.value) return "";
  if (!fenceForm.value.electronicName.trim()) return "请先填写围栏名称";
  if (fenceDraftPoints.value.length < 3) return "请先在地图上绘制运营区";
  const stationOverflowReason = getFenceStationOverflowReason(fenceDraftPoints.value);
  if (stationOverflowReason) return stationOverflowReason;
  if (fenceForm.value.effectType === 2 && !fenceForm.value.effectTime)
    return "请选择生效时间";
  if (fenceForm.value.failureType === 2 && !fenceForm.value.failureTime)
@@ -1309,18 +1393,6 @@
  );
}
function getFenceStationOverflowReason(points: LngLatTuple[]) {
  const overflowStations = getFenceOutOfAreaStations(points);
  if (!overflowStations.length) return "";
  const previewNames = overflowStations
    .slice(0, 2)
    .map((item) => item.name || "未命名场站")
    .join("、");
  return overflowStations.length > 2
    ? `当前运营区调整后,${previewNames}等${overflowStations.length}个场站将部分或全部超出运营区`
    : `当前运营区调整后,${previewNames}将部分或全部超出运营区`;
}
function validateFenceGeometry(points: LngLatTuple[]) {
  if (points.length < 3) {
    window.$message?.error("请绘制至少3个点的围栏");
@@ -1357,11 +1429,6 @@
    window.$message?.error(
      `当前区域包含${containOthersArea.areaName},若需修改,请编辑${containOthersArea.areaName}`
    );
    return false;
  }
  const stationOverflowReason = getFenceStationOverflowReason(points);
  if (stationOverflowReason) {
    window.$message?.error(stationOverflowReason);
    return false;
  }
  return true;
@@ -1641,39 +1708,31 @@
}
function updateFenceStationWarningStyles() {
  const invalidIdSet = new Set(invalidFenceStationIds.value.map(String));
  stationPolygons.value.forEach((polygon: any) => {
    const extData = polygon.getExtData?.() || {};
    const isInvalid = invalidIdSet.has(String(extData.id || ""));
    polygon.setOptions({
      fillColor: isInvalid ? "#E02716" : "#A034FF",
      strokeColor: isInvalid ? "#E02716" : "#A034FF",
      strokeWeight: isInvalid ? 3 : 2,
      fillOpacity: isInvalid ? 0.14 : 0.08,
      zIndex: isInvalid ? 230 : 205,
      fillColor: "#A034FF",
      strokeColor: "#A034FF",
      strokeWeight: 2,
      fillOpacity: 0.08,
      zIndex: 205,
    });
  });
}
function syncFenceStationWarningState(points: LngLatTuple[] = fenceDraftPoints.value) {
  invalidFenceStationIds.value = getFenceOutOfAreaStations(points).map((item) =>
    String(item.id)
  );
  setFenceDraftPolygonStyle(
    fenceInteractionMode.value === "operation-edit",
    invalidFenceStationIds.value.length > 0
  );
  invalidFenceStationIds.value = [];
  setFenceDraftPolygonStyle(fenceInteractionMode.value === "operation-edit");
  updateFenceStationWarningStyles();
}
function setFenceDraftPolygonStyle(editable: boolean, warning = false) {
function setFenceDraftPolygonStyle(editable: boolean) {
  if (!fenceDraftPolygon.value) return;
  fenceDraftPolygon.value.setOptions({
    fillColor: warning ? "#E02716" : "#0068FF",
    strokeColor: warning ? "#E02716" : "#0068FF",
    fillColor: "#0068FF",
    strokeColor: "#0068FF",
    strokeWeight: 2,
    strokeOpacity: 1,
    fillOpacity: warning ? 0.14 : 0.1,
    fillOpacity: 0.1,
    strokeStyle: editable ? "solid" : "dashed",
    cursor: "pointer",
    zIndex: 220,
@@ -3108,7 +3167,12 @@
  pointPanelClosed.value = false;
  await loadStationData();
  if (!stationCreateMode.value && pendingWorkbenchType.value !== "create") {
    selectedId.value = tabDataMap.value[value][0]?.id || "";
    const stationList = tabDataMap.value[value];
    const hasMatchedSelectedStation = stationList.some(
      (item) => String(item.id) === String(selectedId.value)
    );
    if (!hasMatchedSelectedStation)
      selectedId.value = stationList[0]?.id || "";
  }
});
@@ -5433,10 +5497,14 @@
        : { addPointIdList: [], delPointIdList: [], disablePointIdList: [] };
    const disabledPointCount =
      fencePointRelationPayload.disablePointIdList.length;
    if (saveType === 1 && disabledPointCount > 0) {
    const fenceSubmitConfirmMessage =
      saveType === 1
        ? buildFenceSubmitConfirmMessage(disabledPointCount)
        : "";
    if (fenceSubmitConfirmMessage) {
      const confirmed = await openConfirmDialog(
        "是否确认发布",
        `因围栏调整,有${disabledPointCount}个点位将变更为“已停用”,确认发布本次修改?`
        fenceSubmitConfirmMessage
      );
      if (!confirmed) {
        fenceSaving.value = false;
@@ -6087,19 +6155,17 @@
      .map((item) => {
        const isActive =
          activeTab.value === "station" && String(selectedId.value) === String(item.id);
        const isInvalid =
          isFenceFormMode.value && invalidFenceStationIds.value.includes(String(item.id));
        const polygon = new (window as any).AMap.Polygon({
          path: item.polygonPath,
          fillColor: isInvalid ? "#E02716" : "#A034FF",
          strokeColor: isInvalid ? "#E02716" : "#A034FF",
          strokeWeight: isInvalid || isActive ? 3 : 2,
          fillColor: "#A034FF",
          strokeColor: "#A034FF",
          strokeWeight: isActive ? 3 : 2,
          strokeOpacity: 1,
          fillOpacity: isInvalid ? 0.14 : isActive ? 0.18 : 0.08,
          fillOpacity: isActive ? 0.18 : 0.08,
          strokeStyle: "solid",
          bubble: true,
          clickable: !isStationFormMode.value,
          zIndex: isInvalid ? 230 : isActive ? 225 : 205,
          zIndex: isActive ? 225 : 205,
        });
        polygon.on("click", () => {
          if (isPointMapPickActive.value || isStationFormMode.value) return;
@@ -6452,10 +6518,53 @@
        <MapInfoPopupCard
          v-if="showFenceEditMapInfoPopup"
          :style="mapInfoPopupStyle"
          title="超出范围点位"
          :total="fenceOutOfRangePointTotal"
          :available-items="fenceInnerPointSummary.availableItems"
          :unavailable-items="fenceInnerPointSummary.unavailableItems"
          title="围栏影响统计"
          :overview-cards="[
            {
              key: 'point',
              title: '点位',
              total: fenceInnerPointSummary.total,
              available:
                fenceInnerPointSummary.counts['可运营'] +
                fenceInnerPointSummary.counts['待生效'] +
                fenceInnerPointSummary.counts['启用中'],
              unavailable:
                fenceInnerPointSummary.counts['待测试'] +
                fenceInnerPointSummary.counts['测试中'] +
                fenceInnerPointSummary.counts['测试不通过'] +
                fenceInnerPointSummary.counts['已停用'] +
                fenceInnerPointSummary.counts['失效'],
            },
            {
              key: 'station',
              title: '场站',
              total: fenceInnerStationSummary.total,
              available:
                fenceInnerStationSummary.counts['启用中'] +
                fenceInnerStationSummary.counts['待生效'],
              unavailable:
                fenceInnerStationSummary.counts['已停用'] +
                fenceInnerStationSummary.counts['已失效'],
            },
          ]"
          :detail-cards="[
            {
              key: 'remove',
              title: '移出围栏',
              subtitle: '不在圈内',
              pointCount: fenceImpactDeltaSummary.remove.pointCount,
              stationCount: fenceImpactDeltaSummary.remove.stationCount,
              total: fenceImpactDeltaSummary.remove.total,
            },
            {
              key: 'add',
              title: '新增包含',
              subtitle: '进入圈内',
              pointCount: fenceImpactDeltaSummary.add.pointCount,
              stationCount: fenceImpactDeltaSummary.add.stationCount,
              total: fenceImpactDeltaSummary.add.total,
            },
          ]"
        />
        <div v-if="mapLoading" class="map-loading">地图加载中...</div>
        <div v-if="mapError" class="map-error">
src/views/businessOperations/operationWorkbench/status.ts
@@ -90,6 +90,70 @@
  };
}
export interface StationStatusMetricItem {
  label: string;
  value: number;
}
export interface StationStatusMetricSource {
  status?: string;
  areaStatus?: number | string;
}
export interface StationStatusMetricSummary {
  total: number;
  availableItems: StationStatusMetricItem[];
  unavailableItems: StationStatusMetricItem[];
  counts: Record<string, number>;
}
const AVAILABLE_STATION_STATUS_LABELS = ["启用中", "待生效"] as const;
const UNAVAILABLE_STATION_STATUS_LABELS = ["已停用", "已失效"] as const;
function normalizeStationMetricStatus(
  status?: string,
  areaStatus?: number | string
) {
  const rawStatus = String(status || "").trim();
  if (rawStatus === "失效") return "已失效";
  if (rawStatus === "待启用") return "待生效";
  if (rawStatus === "运营中") return "启用中";
  if (!rawStatus) return Number(areaStatus) === 2 ? "已停用" : "启用中";
  return rawStatus;
}
export function buildStationStatusMetricSummary(
  stationList: StationStatusMetricSource[]
): StationStatusMetricSummary {
  const allLabels = [
    ...AVAILABLE_STATION_STATUS_LABELS,
    ...UNAVAILABLE_STATION_STATUS_LABELS,
  ];
  const counts = allLabels.reduce<Record<string, number>>((result, label) => {
    result[label] = 0;
    return result;
  }, {});
  stationList.forEach((item) => {
    const nextStatus = normalizeStationMetricStatus(item.status, item.areaStatus);
    if (Object.prototype.hasOwnProperty.call(counts, nextStatus))
      counts[nextStatus] += 1;
  });
  return {
    total: stationList.length,
    availableItems: AVAILABLE_STATION_STATUS_LABELS.map((label) => ({
      label,
      value: counts[label],
    })),
    unavailableItems: UNAVAILABLE_STATION_STATUS_LABELS.map((label) => ({
      label,
      value: counts[label],
    })),
    counts,
  };
}
export function getStatusClass(status?: string) {
  if (["待测试"].includes(String(status || ""))) return "wait-test";
  if (["测试中"].includes(String(status || ""))) return "testing";