董国庆
2026-06-02 c2bff4b925a137d1a202b4d0c3cb795aae87bc10
Merge branch 'deploy-1.0' into deploy-2.0
5个文件已修改
254 ■■■■■ 已修改文件
src/views/businessOperations/operationWorkbench/components/FencePanel.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/list.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/map.vue 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/workbench.payload.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/active/coupon/BatchSendModal.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/operationWorkbench/components/FencePanel.vue
@@ -113,15 +113,21 @@
  const baseItems = [
    { label: "启用中", value: getStatNumber(source?.startingUp), color: "success" },
    { label: "待生效", value: getStatNumber(source?.pendingLaunch), color: "warning" },
    // { label: "草稿", value: getStatNumber(source?.draft), color: "muted" },
    { label: "已停用", value: getStatNumber(source?.disabled), color: "muted" },
    { label: "已失效", value: getStatNumber(source?.expired), color: "muted" },
  ];
  if (type === "station") return baseItems;
  if (type === "station") {
    return [
      ...baseItems.slice(0, 2),
      { label: "草稿", value: getStatNumber(source?.draft), color: "muted" },
      ...baseItems.slice(2),
    ];
  }
  return [
    ...baseItems,
    { label: "可运营", value: getStatNumber(source?.canOperate), color: "success" },
    { label: "待测试", value: getStatNumber(source?.toBeTested), color: "info" },
    { label: "测试中", value: getStatNumber(source?.testing), color: "info" },
    {
src/views/businessOperations/operationWorkbench/list.vue
@@ -105,6 +105,7 @@
  electronicAreaId: null as string | null,
  statusList: null as number[] | null,
  type: null as number | null,
  typeStr: null as number[] | null,
  lifecycleRange: null as [number, number] | null,
  startTime: '',
  endTime: '',
@@ -190,6 +191,8 @@
const isPointAreaSelectOpen = ref(false)
const isFenceStatusListOpen = ref(false)
const isFenceElectronicAreaIdOpen = ref(false)
const isFenceStrategyTypeOpen = ref(false)
const isFenceStrategyWeekOpen = ref(false)
const isStationStatusOpen = ref(false)
const isStationPointNumOpen = ref(false)
const isFenceCityOpen = ref(false)
@@ -314,6 +317,28 @@
    return labels.length ? labels.join('、') : '-'
  }
  return fenceStrategyDateTypeOptions.find(option => option.value === Number(item?.type))?.label || '-'
}
function normalizeFenceStrategyWeekFilter(value: Array<number | string> | null | undefined) {
  const normalized = (Array.isArray(value) ? value : [])
    .map(item => Number(item))
    .filter(item => Number.isFinite(item))
  if (normalized.includes(0))
    return [0]
  return Array.from(new Set(normalized)).sort((a, b) => a - b)
}
function handleFenceStrategyTypeFilterChange(value: number | null) {
  fenceModel.value.type = value
  if (value !== 3)
    fenceModel.value.typeStr = null
}
function handleFenceStrategyWeekFilterChange(value: Array<number | string> | null) {
  const normalized = normalizeFenceStrategyWeekFilter(value)
  fenceModel.value.typeStr = normalized.length ? normalized : null
}
function formatFenceStrategyTimeList(item: Record<string, any>) {
@@ -644,6 +669,7 @@
    electronicNumberOrName: fenceModel.value.electronicNumberOrName,
    statusList: fenceModel.value.statusList,
    type: fenceModel.value.type,
    typeStr: fenceModel.value.type === 3 ? fenceModel.value.typeStr : null,
    effectTime: fenceModel.value.lifecycleRange ? dayjs(fenceModel.value.lifecycleRange[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss') : null,
    failureTime: fenceModel.value.lifecycleRange ? dayjs(fenceModel.value.lifecycleRange[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss') : null,
    startTime: fenceModel.value.startTime || null,
@@ -1528,11 +1554,24 @@
        path: 'type',
        render: () => (
          <NSelect
            v-model:value={fenceModel.value.type}
            value={fenceModel.value.type}
            options={fenceStrategyTypeOptions}
            placeholder="请选择"
            clearable
          />
            onUpdate:value={handleFenceStrategyTypeFilterChange}
            onUpdate:show={(val: boolean) => isFenceStrategyTypeOpen.value = val}
          >
            {{
              arrow: () => (
                <img
                  src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                  class={`arrow-icon ${isFenceStrategyTypeOpen.value ? 'arrow-icon--open' : ''}`}
                  style={{ width: '16px', height: '16px' }}
                  alt="arrow"
                />
              ),
            }}
          </NSelect>
        ),
      },
      {
@@ -1542,6 +1581,35 @@
          <NDatePicker v-model:value={fenceModel.value.lifecycleRange} type="daterange" clearable actions={[]} close-on-select={true} format="yyyy/MM/dd" />
        ),
      },
      ...(fenceModel.value.type === 3
        ? [{
            label: '自定义周',
            path: 'typeStr',
            render: () => (
              <NSelect
                value={fenceModel.value.typeStr}
                options={fenceStrategyWeekOptions}
                placeholder="请选择自定义周"
                clearable
                multiple
                max-tag-count={1}
                onUpdate:show={(val: boolean) => isFenceStrategyWeekOpen.value = val}
                onUpdate:value={handleFenceStrategyWeekFilterChange}
              >
                {{
                  arrow: () => (
                    <img
                      src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                      class={`arrow-icon ${isFenceStrategyWeekOpen.value ? 'arrow-icon--open' : ''}`}
                      style={{ width: '16px', height: '16px' }}
                      alt="arrow"
                    />
                  ),
                }}
              </NSelect>
            ),
          }]
        : []),
      {
        label: '运营时间',
        path: 'operationTime',
@@ -2335,6 +2403,11 @@
})
const cardClassName = computed(() => activeTab.value === 'point' ? 'point-management-list' : '')
const showBatchExportButton = computed(() => ['point', 'station'].includes(activeTab.value))
const batchExportButtonKey = computed(() => `batch-export-${activeTab.value}`)
const batchExportButtonId = computed(() => activeTab.value === 'station' ? 'exportStationExcel' : 'exportPointExcel')
const batchExportPermissionCode = computed(() => activeTab.value === 'station' ? 202642934 : 202642928)
const batchExportButtonText = computed(() => activeTab.value === 'station' ? '批量导出场站' : '批量导出点位')
const currentOperationLogBusinessType = computed(() => {
  if (activeTab.value === 'fence')
    return 22
@@ -2345,6 +2418,14 @@
function handleOpenOperationLog() {
  showOperationLogModal.value = true
}
function handleBatchExport() {
  if (activeTab.value === 'station') {
    handleStationBatchExport()
    return
  }
  handlePointBatchExport()
}
watch(
@@ -2477,28 +2558,18 @@
            <NButton v-permission="2026051805" @click="handleOpenOperationLog">
              操作日志
            </NButton>
            <template v-if="activeTab === 'point'">
            <template v-if="showBatchExportButton">
              <span v-if="checkedRowKeys.length > 0" class="selected-text">
                已选择{{ checkedRowKeys.length }}条记录
              </span>
              <NButton
                v-permission="202642928"
                :key="batchExportButtonKey"
                :id="batchExportButtonId"
                v-permission="batchExportPermissionCode"
                :disabled="checkedRowKeys.length === 0"
                @click="handlePointBatchExport"
                @click="handleBatchExport"
              >
                批量导出点位
              </NButton>
            </template>
            <template v-else-if="activeTab === 'station'">
              <span v-if="checkedRowKeys.length > 0" class="selected-text">
                已选择 {{ checkedRowKeys.length }} 条记录
              </span>
              <NButton
                v-permission="202642934"
                :disabled="checkedRowKeys.length === 0"
                @click="handleStationBatchExport"
              >
                批量导出场站
                {{ batchExportButtonText }}
              </NButton>
            </template>
          </n-flex>
src/views/businessOperations/operationWorkbench/map.vue
@@ -1233,6 +1233,8 @@
  () => !fenceCreateMode.value && Number(fenceDetailData.value?.status) === 2
);
// 围栏保存前的统一阻断原因。
// 这里不直接执行 message,而是返回首个阻断文案,供按钮禁用态和保存提示复用同一套校验结果。
const fenceSaveBlockReason = computed(() => {
  if (!isFenceFormMode.value) return "";
  if (isLockedFenceStatusEdit.value) return "";
@@ -1257,6 +1259,8 @@
    if (Number(strategy.type) === 3 && !strategy.typeStr.length)
      return `请为 ${strategy.name || "策略组"} 选择生效星期`;
    let typeKey = "";
    // 非自定义策略按固定类型去重;自定义策略按“星期组合”去重。
    // 这样既能防止出现两个“工作日”策略,也允许不同星期组合并存。
    if (Number(strategy.type) === 0) typeKey = "workday";
    else if (Number(strategy.type) === 1) typeKey = "statutoryHoliday";
    else if (Number(strategy.type) === 2) typeKey = "holiday";
@@ -1267,6 +1271,7 @@
    if (typeKey && strategyTypeKeys.has(typeKey))
      return `运营策略存在重复:${strategy.name || "策略组"} 的类型已存在`;
    if (typeKey) strategyTypeKeys.add(typeKey);
    // “每天”覆盖全部日期范围,因此不允许再和其他策略组并存。
    if (typeKey.includes("custom-0") && fenceStrategyList.value.length > 1)
      return "选择“每天”时,不能添加其他运营策略";
    if (!strategy.time.length)
@@ -1298,6 +1303,7 @@
  }
  for (const area of fenceRestrictedAreas.value) {
    if (!area.areaName.trim()) return "请填写禁行区名称";
    // 禁行区只要开始绘制,就必须形成完整 polygon,避免保存半成品区域。
    if (area.points.length > 0 && area.points.length < 3)
      return `${area.areaName || "禁行区"} 还未完成绘制`;
    if (!area.points.length) return `${area.areaName || "禁行区"} 还未完成绘制`;
@@ -1305,6 +1311,8 @@
  return "";
});
// 场站在“自定义时间”模式下的时间段校验。
// 这里和围栏策略校验拆开,便于场站表单保存、联动校验及错误提示单独复用。
function getStationCustomTimeErrorMessage() {
  if (stationForm.value.operationType !== "2") return "";
  const ranges = Array.isArray(stationForm.value.customTimeRanges)
@@ -1333,6 +1341,7 @@
  return "";
}
// 编辑态下通过快照判断场站表单是否被修改,用于离开编辑态时提示是否放弃更改。
const stationEditPayloadSnapshot = computed(() => {
  return JSON.stringify({
    stationName: stationForm.value.stationName.trim(),
@@ -1351,12 +1360,25 @@
  });
});
// 场站保存前的统一阻断原因。
// 与围栏类似,返回首个错误文案,供按钮禁用态、保存前提示、局部联动共用。
const stationSaveBlockReason = computed(() => {
  if (!isStationFormMode.value) return "";
  if (!stationForm.value.stationName.trim()) return "请输入场站名称";
  if (stationDraftPoints.value.length < 3) return "请先在运营区内绘制场站";
  const geometryError = getStationGeometryInvalidReason(stationDraftPoints.value);
  const geometryError = getStationGeometryInvalidReason(stationDraftPoints.value, {
    allowOutsideOperationArea: isStationEditWorkflow.value,
  });
  if (geometryError) return geometryError;
  // 编辑已有场站时允许它暂时落在围栏外,方便修复历史数据或承接围栏变更后的残留场站;
  // 但只要当前没有匹配到任何运营区,就不能把状态切回“启用”。
  if (
    isStationEditWorkflow.value &&
    !resolveStationOperationAreaByPolygon(stationDraftPoints.value) &&
    Number(stationForm.value.areaStatus || 1) === 1
  ) {
    return "当前场站不在围栏区域内,不允许启用";
  }
  if (!stationForm.value.parkingTimeLimit) return "请输入限停时长";
  return getStationCustomTimeErrorMessage();
});
@@ -1605,17 +1627,37 @@
    .filter((item) => item.points.length >= 3);
}
function getStationGeometryInvalidReason(points: LngLatTuple[]) {
// 根据整个场站 polygon 反查所属运营区。
// 这里不使用中心点命中,而是要求“场站整体完全落在某个运营区内”,
// 以便和保存校验、围栏解绑逻辑保持一致。
function resolveStationOperationAreaByPolygon(points: LngLatTuple[]) {
  if (points.length < 3) return null;
  return (
    getStationOperationAreas().find((area) => isRingInRing(points, area.points))
    || null
  );
}
// 统一处理场站几何合法性校验:
// 1. 必须构成合法 polygon
// 2. 新建时必须在运营区内,编辑时允许临时落在围栏外
// 3. 不允许与其他场站重叠/包含
// 4. 不允许与启用中的禁行区重叠
function getStationGeometryInvalidReason(
  points: LngLatTuple[],
  options: {
    allowOutsideOperationArea?: boolean;
  } = {}
) {
  if (points.length < 3) return "请绘制至少3个点的场站";
  if (isFencePolygonSelfIntersect(points)) return "场站区域不允许自相交";
  const operationAreas = getStationOperationAreas();
  const isInsideOperationArea = operationAreas.some((area) =>
    isRingInRing(points, area.points)
  );
  if (!isInsideOperationArea) return "场站需完全处于运营区内";
  const matchedOperationArea = resolveStationOperationAreaByPolygon(points);
  if (!matchedOperationArea && !options.allowOutsideOperationArea)
    return "场站需完全处于运营区内";
  const otherStations = getOtherStations();
  const geometryUtil = (window as any).AMap?.GeometryUtil;
  if (geometryUtil) {
    // 先拦截相交,再拦截包含/被包含,提示更符合用户的实际绘制问题。
    const crossStation = otherStations.find((station) =>
      geometryUtil.doesRingRingIntersect(points, station.points)
    );
@@ -1639,8 +1681,12 @@
  return "";
}
// 地图交互层的场站几何校验封装。
// 与纯函数校验分开,专门负责在用户绘制/拖拽时弹出 warning。
function validateStationGeometry(points: LngLatTuple[]) {
  const errorMessage = getStationGeometryInvalidReason(points);
  const errorMessage = getStationGeometryInvalidReason(points, {
    allowOutsideOperationArea: isStationEditWorkflow.value,
  });
  if (errorMessage) {
    window.$message?.warning(errorMessage);
    return false;
@@ -1648,6 +1694,8 @@
  return true;
}
// 按场站 polygon 自动同步框选到的点位列表。
// 保留已匹配点位的原有顺序,只把新增命中的点位追加到末尾,避免用户编辑时列表频繁跳动。
function syncStationPointListByPolygon(path: LngLatTuple[]) {
  const nextMatchedList = getPointItemsWithinPolygon(path).map(
    mapListItemToStationPoint
@@ -1705,6 +1753,8 @@
  });
}
// 基于当前草稿点位重建地图上的场站 polygon。
// 只负责地图图形本身,不负责同步表单/点位列表,避免图形重建时产生额外副作用。
function createStationDraftPolygon(points: LngLatTuple[]) {
  if (!map.value || points.length < 3) return;
  if (stationDraftPolygon.value) map.value.remove(stationDraftPolygon.value);
@@ -1726,6 +1776,8 @@
  map.value.add(stationDraftPolygon.value);
}
// 首次应用一组完整的场站草稿几何信息。
// 用于详情回显、新建绘制完成后落稿,同时按最新 polygon 自动同步场站内点位。
function applyStationDraftGeometry(points: LngLatTuple[], fitView = false) {
  stationDraftPoints.value = clonePolygonPath(points);
  stationForm.value.polygonPath = clonePolygonPath(points);
@@ -1735,12 +1787,16 @@
    map.value.setFitView([stationDraftPolygon.value], false, [100, 100, 100, 100]);
}
// 编辑器拖拽/加点/删点过程中的轻量同步。
// 这里只更新内存态,不重建 polygon,避免编辑过程出现闪烁。
function syncStationDraftGeometryState(points: LngLatTuple[]) {
  stationDraftPoints.value = clonePolygonPath(points);
  stationForm.value.polygonPath = clonePolygonPath(points);
  syncStationPointListByPolygon(points);
}
// 当编辑后的几何不合法时,回滚到上一次合法草稿,并重新挂回编辑器。
// 这里通过 rollbacking 标记和 suppress 标记避免 close/open 编辑器时触发重复 end 事件。
async function rollbackStationDraftGeometry() {
  if (!map.value || stationDraftPoints.value.length < 3 || stationEditorRollbacking)
    return;
@@ -1759,6 +1815,8 @@
  enableStationDraftEdit();
}
// 打开场站 polygon 的可编辑态,并把 AMap 编辑事件同步回表单草稿。
// adjust/addnode/removenode 过程中先实时校验,若非法则立即回滚,确保草稿始终保持可保存状态。
function enableStationDraftEdit() {
  if (!map.value || !stationDraftPolygon.value || stationEditorRollbacking) return;
  if (stationPolyEditor.value) suppressStationEditorEnd.value = true;
@@ -1786,6 +1844,7 @@
      editOptions: { fillColor: "#A034FF" },
    }
  );
  // 统一从 polygon 实例回读最新 path,避免不同编辑事件各自维护一套取值逻辑。
  const syncStationGeometry = () => {
    if (!stationDraftPolygon.value || stationEditorRollbacking) return;
    const points = stationDraftPolygon.value
@@ -1797,6 +1856,7 @@
    }
    syncStationDraftGeometryState(points);
  };
  // 主动 close 编辑器时会触发 end,这里用 suppress 标记过滤掉非用户操作的结束事件。
  const handleStationGeometryEditEnd = () => {
    if (suppressStationEditorEnd.value) {
      suppressStationEditorEnd.value = false;
@@ -2792,23 +2852,14 @@
function resolveStationOperationArea() {
  if (!fenceAreaData.value.length) return null;
  const draftPolygonPath = clonePolygonPath(stationDraftPoints.value);
  const draftCenter = getPolygonCenter(draftPolygonPath);
  const centerLon = Number.isFinite(Number(draftCenter?.[0]))
    ? Number(draftCenter?.[0])
    : selectedItem.value?.type === "station"
    ? Number(selectedItem.value.lng)
    : NaN;
  const centerLat = Number.isFinite(Number(draftCenter?.[1]))
    ? Number(draftCenter?.[1])
    : selectedItem.value?.type === "station"
    ? Number(selectedItem.value.lat)
    : NaN;
  if (!Number.isFinite(centerLon) || !Number.isFinite(centerLat)) return null;
  return resolveOperationArea(
    fenceAreaData.value,
    centerLon,
    centerLat
  if (draftPolygonPath.length >= 3)
    return resolveStationOperationAreaByPolygon(draftPolygonPath);
  const detailPolygonPath = clonePolygonPath(
    stationDetailData.value?.polygonPath || stationForm.value.polygonPath
  );
  if (detailPolygonPath.length >= 3)
    return resolveStationOperationAreaByPolygon(detailPolygonPath);
  return null;
}
const stationLifecycleCache = ref<Record<string, Record<string, any> | null>>({});
@@ -2862,6 +2913,7 @@
}
const stationOperationLifecycle = computed(() => {
  if (!stationOperationArea.value) return null;
  const detailLifecycle = normalizeStationLifecycleSource(stationDetailData.value);
  if (detailLifecycle) return detailLifecycle;
  return stationOperationLifecycleFallback.value;
@@ -3381,14 +3433,7 @@
      ? Number(polygonCenter?.[1])
      : undefined;
    const currentSelectedStation = selectedItem.value;
    const operationArea =
      polygonCenter && fenceAreaData.value.length
        ? resolveOperationArea(
            fenceAreaData.value,
            Number(polygonCenter[0]),
            Number(polygonCenter[1])
          )
        : null;
    const operationArea = resolveStationOperationAreaByPolygon(polygonPath);
    const stationArea = Number(calcFenceArea(polygonPath).toFixed(2));
    const detail = {
      id:
@@ -3493,6 +3538,14 @@
  }
}
watch(
  [isStationEditWorkflow, stationOperationArea, () => stationForm.value.areaStatus],
  ([editing, operationArea, areaStatus]) => {
    if (!editing || operationArea || Number(areaStatus) !== 1) return;
    stationForm.value.areaStatus = 2;
  }
);
// tab 切换时重置左侧筛选与选中态,并按目标 tab 刷新对应资源。
watch(activeTab, async (value) => {
  keyword.value = "";
src/views/businessOperations/operationWorkbench/workbench.payload.ts
@@ -337,6 +337,7 @@
  );
  // 场站保存状态码。
  const statusCode = getStationStatusCode(saveType, areaStatus);
  const shouldClearOperationArea = !isCreatingStation && !operationArea;
  return omitUndefinedFields({
    ...(isCreatingStation ? {} : { id: Number(detailId) }),
@@ -347,11 +348,15 @@
    electronicAreaId:
      operationArea?.id !== undefined && operationArea?.id !== null
        ? Number(operationArea.id)
        : shouldClearOperationArea
        ? null
        : undefined,
    electronicId:
      operationArea?.electronicId !== undefined &&
      operationArea?.electronicId !== null
        ? Number(operationArea.electronicId)
        : shouldClearOperationArea
        ? null
        : undefined,
    fencePoints: stringifyFencePoints(polygonPath) || undefined,
    operationType: Number(operationType || 1),
src/views/userContent/active/coupon/BatchSendModal.vue
@@ -78,6 +78,18 @@
  getList()
}
function resetModalState() {
  searchModel.value = {
    account: '',
    whitelistSource: null,
    userType: null,
    status: null,
  }
  checkedRowKeys.value = []
  currentPage.value = 1
  pageSize.value = 10
}
const isNormalStatus = (status: unknown) => status === 1 || status === true
const columns: DataTableColumns<any> = [
@@ -137,6 +149,9 @@
    checkedRowKeys.value = []
    getList()
  }
  else {
    resetModalState()
  }
})
function handlePageChange(page: number, size: number) {