| src/views/businessOperations/operationWorkbench/components/FencePanel.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/businessOperations/operationWorkbench/list.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/businessOperations/operationWorkbench/map.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/businessOperations/operationWorkbench/workbench.payload.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/userContent/active/coupon/BatchSendModal.vue | ●●●●● 补丁 | 查看 | 原始文档 | 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) {