<script setup lang="ts">
|
import dayjs from "dayjs";
|
import {
|
NButton,
|
NDatePicker,
|
NForm,
|
NFormItem,
|
NInput,
|
NSelect,
|
NSwitch,
|
NTag,
|
NTimePicker,
|
} from "naive-ui";
|
import type { FormInst, FormItemRule } from "naive-ui";
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
import closeIcon from "@/assets/geofence/close2.png";
|
import timeIcon from "@/assets/geofence/time.png";
|
import addGroupIcon from "@/assets/geofence/add.png";
|
import addTimeIcon from "@/assets/geofence/add2.png";
|
import editGroupIcon from "@/assets/geofence/edit.png";
|
import editRestrictedIcon from "@/assets/geofence/edit1.png";
|
import delIcon from "@/assets/geofence/del.png";
|
import noEntryIcon from "@/assets/geofence/noEntry.png";
|
|
const props = defineProps<{
|
editMode: boolean;
|
createMode?: boolean;
|
saving?: boolean;
|
saveBlockReason: string;
|
form: Record<string, any>;
|
strategyList: Array<Record<string, any>>;
|
detailRows: Array<{ label: string; value: string }>;
|
fenceDetailData: Record<string, any> | null;
|
strategySummary: Array<Record<string, any>>;
|
areaSummary: {
|
operationAreas: Array<Record<string, any>>;
|
restrictedAreas: Array<Record<string, any>>;
|
};
|
}>();
|
|
const emit = defineEmits([
|
"remove-strategy",
|
"add-strategy-time",
|
"remove-strategy-time",
|
"add-strategy",
|
"week-selection-change",
|
"add-restricted-area",
|
"edit-restricted-area",
|
"remove-restricted-area",
|
"toggle-restricted-area-status",
|
"focus-restricted-area",
|
"view-source",
|
"edit-current",
|
"close-panel",
|
"cancel-edit",
|
"save-draft",
|
"save-execute",
|
]);
|
|
const formRef = ref<FormInst | null>(null);
|
const effectTimePicker = ref<any>(null);
|
const failureTimePicker = ref<any>(null);
|
const mainScrollRef = ref<HTMLElement | null>(null);
|
const showDrawerHead = ref(false);
|
|
const regionTypeOptions = [{ label: "运营区", value: 1 }];
|
|
const dateOptions = [
|
{ label: "工作日", value: 0 },
|
{ label: "非工作日", value: 2 },
|
{ label: "法定节假日", value: 1 },
|
{ label: "自定义", value: 3 },
|
];
|
|
const weekOptions = [
|
{ label: "每天", value: 0 },
|
{ label: "星期一", value: 1 },
|
{ label: "星期二", value: 2 },
|
{ label: "星期三", value: 3 },
|
{ label: "星期四", value: 4 },
|
{ label: "星期五", value: 5 },
|
{ label: "星期六", value: 6 },
|
{ label: "星期日", value: 7 },
|
];
|
|
const isLocked = computed(
|
() => !props.createMode && Number(props.fenceDetailData?.status) === 2
|
);
|
const isAreaStatusSwitchDisabled = computed(
|
() =>
|
!props.createMode && [0, 1].includes(Number(props.fenceDetailData?.status))
|
);
|
const showDraftButton = computed(
|
() => !!props.createMode || Number(props.fenceDetailData?.status) === 0
|
);
|
const operationArea = computed(
|
() => props.areaSummary.operationAreas?.[0] || null
|
);
|
const fenceEntityStatSections = computed(() => {
|
const buildSection = (
|
title: string,
|
source?: Record<string, any> | null
|
) => {
|
const toCount = (value: unknown) => {
|
const count = Number(value);
|
return Number.isFinite(count) ? count : 0;
|
};
|
|
const items = [
|
{ label: "草稿", value: toCount(source?.draft) },
|
{ label: "启用中", value: toCount(source?.startingUp) },
|
{ label: "待生效", value: toCount(source?.pendingLaunch) },
|
{ label: "已停用", value: toCount(source?.disabled) },
|
{ label: "已失效", value: toCount(source?.expired) },
|
];
|
|
const visibleItems = items.filter((item) => item.value > 0);
|
|
return {
|
title,
|
total: toCount(source?.totalNum),
|
items: visibleItems,
|
};
|
};
|
|
return [
|
buildSection("点位统计", props.fenceDetailData?.pointsNumbers),
|
buildSection("场站统计", props.fenceDetailData?.stationNumbers),
|
];
|
});
|
const effectTimeText = ref("");
|
const failureTimeText = ref("");
|
|
function disablePreviousDate(ts: number) {
|
const today = new Date();
|
today.setHours(0, 0, 0, 0);
|
return ts < today.getTime();
|
}
|
|
function disablePreviousTime(ts: number) {
|
const current = new Date();
|
const selected = new Date(ts);
|
if (
|
selected.getDate() === current.getDate() &&
|
selected.getMonth() === current.getMonth() &&
|
selected.getFullYear() === current.getFullYear()
|
) {
|
return {
|
isHourDisabled: (hour: number) => hour < current.getHours(),
|
isMinuteDisabled: (minute: number, hour: number | null) =>
|
hour === current.getHours() && minute < current.getMinutes(),
|
isSecondDisabled: (
|
second: number,
|
minute: number | null,
|
hour: number | null
|
) => {
|
if (hour === current.getHours() && minute === current.getMinutes())
|
return second < current.getSeconds();
|
return false;
|
},
|
};
|
}
|
return {};
|
}
|
|
const rules = {
|
electronicName: {
|
required: true,
|
message: "请输入区域名称",
|
trigger: "blur",
|
},
|
areaType: {
|
type: "number",
|
required: true,
|
message: "请选择区域类型",
|
trigger: "change",
|
} as FormItemRule,
|
effectTime: {
|
trigger: ["blur", "change"],
|
validator: (_rule: any, value: any) => {
|
if (
|
Number(props.form.effectType) === 1 &&
|
effectTimeText.value === "立即生效"
|
)
|
return true;
|
if (value == null) return new Error("请选择生效时间");
|
if (typeof value === "number") {
|
if (!props.createMode && Number(props.fenceDetailData?.status) === 2)
|
return true;
|
const now = new Date();
|
const minAllowedDateTime = new Date(
|
now.getFullYear(),
|
now.getMonth(),
|
now.getDate(),
|
now.getHours(),
|
0,
|
0,
|
0
|
);
|
if (value < minAllowedDateTime.getTime())
|
return new Error("生效时间不能早于当前时间");
|
}
|
return true;
|
},
|
} as FormItemRule,
|
failureTime: {
|
trigger: ["blur", "change"],
|
validator: (_rule: any, value: any) => {
|
if (
|
Number(props.form.failureType) === 1 &&
|
failureTimeText.value === "永不失效"
|
)
|
return true;
|
if (value == null) return new Error("请选择失效时间");
|
if (
|
Number(props.form.effectType) === 1 &&
|
effectTimeText.value === "立即生效" &&
|
typeof value === "number"
|
) {
|
if (value <= Date.now()) return new Error("失效时间不能小于当前时间");
|
return true;
|
}
|
if (
|
typeof props.form.effectTime === "number" &&
|
typeof value === "number" &&
|
value <= props.form.effectTime
|
)
|
return new Error("失效时间必须大于生效时间");
|
return true;
|
},
|
} as FormItemRule,
|
};
|
|
function getFenceStatusText(status: number) {
|
if (Number(status) === 0) return "草稿";
|
if (Number(status) === 1) return "待生效";
|
if (Number(status) === 2) return "启用中";
|
if (Number(status) === 3) return "已停用";
|
return "-";
|
}
|
|
function getFenceStatusTagType(status: number) {
|
if (Number(status) === 2) return "success";
|
if (Number(status) === 1) return "warning";
|
return "default";
|
}
|
|
function getStrategyTypeText(item: Record<string, any>) {
|
if (Number(item?.type) === 3) {
|
const labels = (Array.isArray(item?.typeStr) ? item.typeStr : [])
|
.map(
|
(value) =>
|
weekOptions.find((option) => option.value === Number(value))?.label
|
)
|
.filter(Boolean);
|
return labels.length ? labels.join("、") : "-";
|
}
|
return (
|
dateOptions.find((option) => option.value === Number(item?.type))?.label ||
|
"-"
|
);
|
}
|
|
function formatStrategyTimeList(item: Record<string, any>) {
|
const times = Array.isArray(item?.time) ? item.time : [];
|
return times
|
.map((sub: any) => {
|
if (typeof sub === "string") return sub;
|
if (sub?.startTime && sub?.endTime)
|
return `${sub.startTime}-${sub.endTime}`;
|
return "";
|
})
|
.filter(Boolean);
|
}
|
|
function isEndTimeDisabledByStart(
|
hour: number,
|
minute: number | undefined,
|
sub: any
|
) {
|
if (!sub?.startTime) return false;
|
const [startH, startM] = String(sub.startTime).split(":").map(Number);
|
if (minute === undefined) return hour < startH;
|
if (hour < startH) return true;
|
if (hour === startH) return minute <= startM;
|
return false;
|
}
|
|
function handleStrategyTypeChange(
|
strategy: Record<string, any>,
|
value: number
|
) {
|
if (isLocked.value) return;
|
strategy.type = value;
|
strategy.typeStr = [];
|
strategy.errorMsg = "";
|
}
|
|
function handleEffectTimeChange(value: number | null) {
|
props.form.effectType = 2;
|
props.form.effectTime = value;
|
void revalidateField("effectTime");
|
}
|
|
function handleFailureTimeChange(value: number | null) {
|
props.form.failureType = 2;
|
props.form.failureTime = value;
|
void revalidateField("failureTime");
|
}
|
|
function handleEffectImmediate() {
|
props.form.effectTime = null;
|
effectTimeText.value = "立即生效";
|
props.form.effectType = 1;
|
effectTimePicker.value?.handlePanelClose?.();
|
void revalidateField("effectTime");
|
}
|
|
function handleFailureForever() {
|
props.form.failureTime = null;
|
failureTimeText.value = "永不失效";
|
props.form.failureType = 1;
|
failureTimePicker.value?.handlePanelClose?.();
|
void revalidateField("failureTime");
|
}
|
|
function getStartPickerStatus(strategy: any, timeItem: any) {
|
if (
|
((strategy.errorMsg === "开始时间和结束时间不能为空" ||
|
strategy.errorMsg === "时间段不能为空") &&
|
!timeItem.startTime) ||
|
strategy.errorMsg === "同一个策略的时段不能重叠"
|
)
|
return "error";
|
return timeItem.startTime ? "success" : undefined;
|
}
|
|
function getEndPickerStatus(strategy: any, timeItem: any) {
|
if (
|
((strategy.errorMsg === "开始时间和结束时间不能为空" ||
|
strategy.errorMsg === "时间段不能为空") &&
|
!timeItem.endTime) ||
|
(strategy.errorMsg === "结束时间不能小于或等于开始时间" &&
|
timeItem.startTime >= timeItem.endTime) ||
|
strategy.errorMsg === "同一个策略的时段不能重叠"
|
) {
|
return "error";
|
}
|
return timeItem.endTime ? "success" : undefined;
|
}
|
|
async function validateForm() {
|
await nextTick();
|
await formRef.value?.validate();
|
}
|
|
async function revalidateField(path: "effectTime" | "failureTime") {
|
await nextTick();
|
try {
|
await formRef.value?.validate?.(
|
() => {},
|
(rule: any) => String(rule?.key ?? rule?.path ?? "") === path
|
);
|
} catch {
|
// Keep field error state managed by Naive UI when validation still fails.
|
}
|
}
|
|
function handleScroll() {
|
if (!mainScrollRef.value) {
|
showDrawerHead.value = false;
|
return;
|
}
|
showDrawerHead.value = mainScrollRef.value.scrollTop > 0;
|
}
|
|
watch(
|
() => props.form.effectTime,
|
(newVal) => {
|
if (typeof newVal === "number") {
|
effectTimeText.value = dayjs(newVal).format("YYYY-MM-DD HH:mm:ss");
|
props.form.effectType = 2;
|
return;
|
}
|
effectTimeText.value =
|
Number(props.form.effectType) === 1 ? "立即生效" : "";
|
},
|
{ immediate: true }
|
);
|
|
watch(
|
() => props.form.failureTime,
|
(newVal) => {
|
if (typeof newVal === "number") {
|
failureTimeText.value = dayjs(newVal).format("YYYY-MM-DD HH:mm:ss");
|
props.form.failureType = 2;
|
return;
|
}
|
failureTimeText.value =
|
Number(props.form.failureType) === 1 ? "永不失效" : "";
|
},
|
{ immediate: true }
|
);
|
|
watch(
|
() => mainScrollRef.value,
|
(newVal, oldVal) => {
|
oldVal?.removeEventListener("scroll", handleScroll);
|
if (newVal) {
|
newVal.addEventListener("scroll", handleScroll);
|
handleScroll();
|
return;
|
}
|
showDrawerHead.value = false;
|
}
|
);
|
|
onBeforeUnmount(() => {
|
mainScrollRef.value?.removeEventListener("scroll", handleScroll);
|
});
|
|
defineExpose({ validateForm });
|
</script>
|
|
<template>
|
<div class="fence-panel">
|
<template v-if="editMode">
|
<div ref="mainScrollRef" class="main-content">
|
<div class="main-head" :class="{ 'sticky-top': showDrawerHead }">
|
<div v-if="createMode" class="flex items-center gap-4px mb-16px">
|
<div class="title">围栏详情</div>
|
<n-switch
|
v-model:value="form.areaStatus"
|
:checked-value="1"
|
:unchecked-value="2"
|
>
|
<template #checked> 启用 </template>
|
<template #unchecked> 停用 </template>
|
</n-switch>
|
<img
|
:src="closeIcon"
|
class="w-24px h-24px ml-auto cursor-pointer"
|
alt="close"
|
@click="emit('close-panel')"
|
/>
|
</div>
|
<div v-else class="flex items-center gap-4px mb-4px">
|
<div class="title items-center">
|
{{
|
fenceDetailData?.electronicName ||
|
form.electronicName ||
|
"围栏详情"
|
}}
|
<NTag
|
style="font-weight: 500"
|
:type="
|
getFenceStatusTagType(Number(fenceDetailData?.status || 0))
|
"
|
:bordered="false"
|
class="h-24px mx-4px"
|
>
|
{{ getFenceStatusText(Number(fenceDetailData?.status || 0)) }}
|
</NTag>
|
<n-switch
|
v-model:value="form.areaStatus"
|
:checked-value="1"
|
:unchecked-value="2"
|
:disabled="isAreaStatusSwitchDisabled"
|
class="text-13px"
|
>
|
<template #checked> 启用 </template>
|
<template #unchecked> 停用 </template>
|
</n-switch>
|
</div>
|
<img
|
:src="closeIcon"
|
class="w-24px h-24px ml-auto cursor-pointer"
|
alt="close"
|
@click="emit('close-panel')"
|
/>
|
</div>
|
</div>
|
|
<div
|
v-if="!createMode && fenceDetailData"
|
class="flex flex-col gap-4px px-12px pb-16px"
|
>
|
<div class="head-des">
|
围栏ID: {{ fenceDetailData.electronicNumber || "-" }}
|
</div>
|
<div class="head-des">
|
覆盖面积: {{ operationArea?.area || "-" }}km²
|
</div>
|
</div>
|
|
<div class="line" />
|
|
<n-form
|
ref="formRef"
|
class="px-12px py-16px"
|
:rules="rules"
|
:model="form"
|
require-mark-placement="left"
|
>
|
<div class="gap-16px flex flex-col">
|
<div class="box flex flex-col">
|
<div class="box-title mb-8px">基本信息</div>
|
<n-form-item label="区域名称" path="electronicName">
|
<n-input
|
v-model:value="form.electronicName"
|
clearable
|
maxlength="50"
|
:disabled="isLocked"
|
/>
|
</n-form-item>
|
<n-form-item label="区域类型" path="areaType">
|
<n-select
|
v-model:value="form.areaType"
|
:options="regionTypeOptions"
|
clearable
|
/>
|
</n-form-item>
|
<n-form-item label="描述" path="areaDescription">
|
<n-input
|
v-model:value="form.areaDescription"
|
type="textarea"
|
maxlength="200"
|
show-count
|
clearable
|
:disabled="isLocked"
|
/>
|
</n-form-item>
|
</div>
|
|
<div class="box flex flex-col">
|
<div class="box-title mb-8px">生命周期</div>
|
<n-form-item
|
label="生效时间"
|
path="effectTime"
|
class="position-relative"
|
>
|
<n-input
|
v-model:value="effectTimeText"
|
type="text"
|
class="w-full"
|
placeholder="请选择"
|
:disabled="isLocked"
|
>
|
<template #suffix>
|
<img
|
:src="timeIcon"
|
class="w-16px h-16px block"
|
alt="time"
|
/>
|
</template>
|
</n-input>
|
<div class="position">
|
<n-date-picker
|
ref="effectTimePicker"
|
v-model:value="form.effectTime"
|
type="datetime"
|
clearable
|
class="w-full opacity-0"
|
:disabled="isLocked"
|
format="yyyy/MM/dd HH:mm"
|
:actions="[]"
|
:update-value-on-close="true"
|
:is-date-disabled="disablePreviousDate"
|
:is-time-disabled="disablePreviousTime"
|
:time-picker-props="{
|
onConfirm: (val: number | null) => {
|
form.effectTime = val;
|
effectTimePicker?.handlePanelClose?.();
|
},
|
}"
|
@clear="
|
() => {
|
form.effectType = 2;
|
effectTimeText = '';
|
void revalidateField('effectTime');
|
}
|
"
|
>
|
<template #footer>
|
<div class="flex justify-between items-center w-full">
|
<NButton
|
text
|
type="primary"
|
class="text-14px!"
|
@click="handleEffectImmediate"
|
>
|
立即生效
|
</NButton>
|
</div>
|
</template>
|
</n-date-picker>
|
</div>
|
</n-form-item>
|
<n-form-item
|
label="失效时间"
|
path="failureTime"
|
class="position-relative"
|
>
|
<n-input
|
v-model:value="failureTimeText"
|
type="text"
|
class="w-full"
|
placeholder="请选择"
|
:disabled="isLocked"
|
>
|
<template #suffix>
|
<img
|
:src="timeIcon"
|
class="w-16px h-16px block"
|
alt="time"
|
/>
|
</template>
|
</n-input>
|
<div class="position">
|
<n-date-picker
|
ref="failureTimePicker"
|
v-model:value="form.failureTime"
|
type="datetime"
|
clearable
|
class="w-full opacity-0"
|
:disabled="isLocked"
|
format="yyyy/MM/dd HH:mm"
|
:actions="[]"
|
:update-value-on-close="true"
|
:close-on-select="true"
|
:is-date-disabled="disablePreviousDate"
|
:is-time-disabled="disablePreviousTime"
|
:time-picker-props="{
|
onConfirm: (val: number | null) => {
|
form.failureTime = val;
|
failureTimePicker?.handlePanelClose?.();
|
},
|
}"
|
@clear="
|
() => {
|
form.failureType = 2;
|
failureTimeText = '';
|
void revalidateField('failureTime');
|
}
|
"
|
>
|
<template #footer>
|
<div class="flex justify-between items-center w-full">
|
<NButton
|
text
|
type="primary"
|
class="text-14px!"
|
@click="handleFailureForever"
|
>
|
永不失效
|
</NButton>
|
</div>
|
</template>
|
</n-date-picker>
|
</div>
|
</n-form-item>
|
</div>
|
|
<div class="box flex flex-col gap-8px">
|
<div class="box-title">运营策略</div>
|
<div
|
v-for="strategy in strategyList"
|
:key="strategy.id"
|
class="group"
|
>
|
<div class="flex flex-col gap-12px">
|
<div class="group-header">
|
<div v-if="!strategy.edit" class="group-title">
|
{{ strategy.name }}
|
</div>
|
<n-input
|
v-else
|
v-model:value="strategy.name"
|
clearable
|
@keydown.enter="strategy.edit = false"
|
@blur="strategy.edit = false"
|
/>
|
<img
|
v-if="!strategy.edit && !isLocked"
|
:src="editGroupIcon"
|
class="icon"
|
alt="edit"
|
@click="strategy.edit = true"
|
/>
|
<img
|
v-if="!isLocked"
|
src="../../../../assets/geofence/close.png"
|
class="icon"
|
alt="delete"
|
@click="emit('remove-strategy', strategy.id)"
|
/>
|
</div>
|
<div class="flex flex-col gap-12px">
|
<div class="field-block">
|
<div class="field-label">日期选择</div>
|
<div class="flex items-center flex-wrap gap-8px">
|
<div
|
v-for="sub in dateOptions"
|
:key="sub.value"
|
class="btn"
|
:class="{ active: strategy.type === sub.value }"
|
:style="{
|
cursor: isLocked ? 'not-allowed' : 'pointer',
|
}"
|
@click="handleStrategyTypeChange(strategy, sub.value)"
|
>
|
{{ sub.label }}
|
</div>
|
<n-select
|
v-if="strategy.type === 3"
|
v-model:value="strategy.typeStr"
|
class="flex-1"
|
:options="weekOptions"
|
:disabled="strategy.type !== 3 || isLocked"
|
multiple
|
:max-tag-count="1"
|
clearable
|
@update:value="
|
(value) =>
|
emit('week-selection-change', value, strategy)
|
"
|
/>
|
</div>
|
</div>
|
<div class="flex flex-col gap-8px">
|
<div
|
v-for="timeItem in strategy.time"
|
:key="timeItem.id"
|
class="gap-8px flex items-center time-range"
|
>
|
<n-time-picker
|
v-model:formatted-value="timeItem.startTime"
|
format="HH:mm"
|
value-format="HH:mm"
|
placeholder="请选择"
|
clearable
|
:disabled="isLocked"
|
:status="getStartPickerStatus(strategy, timeItem)"
|
:actions="['now']"
|
class="custom-time-picker"
|
@update:value="strategy.errorMsg = ''"
|
/>
|
至
|
<n-time-picker
|
v-model:formatted-value="timeItem.endTime"
|
format="HH:mm"
|
value-format="HH:mm"
|
placeholder="请选择"
|
clearable
|
:disabled="isLocked"
|
:actions="['now']"
|
:status="getEndPickerStatus(strategy, timeItem)"
|
:is-hour-disabled="(hour: number) => isEndTimeDisabledByStart(hour, undefined, timeItem)"
|
:is-minute-disabled="(minute: number, hour: number | null) => isEndTimeDisabledByStart(Number(hour || 0), minute, timeItem)"
|
class="custom-time-picker"
|
@update:value="strategy.errorMsg = ''"
|
/>
|
<img
|
v-if="!isLocked"
|
:src="delIcon"
|
class="w-[16px] h-[16px] cursor-pointer"
|
alt="delete"
|
@click="
|
emit('remove-strategy-time', strategy, timeItem.id)
|
"
|
/>
|
</div>
|
<div
|
v-if="strategy.errorMsg"
|
class="text-[#e02716] text-12px"
|
>
|
{{ strategy.errorMsg }}
|
</div>
|
<button
|
v-if="!isLocked"
|
type="button"
|
class="addTime"
|
@click="emit('add-strategy-time', strategy)"
|
>
|
<img :src="addTimeIcon" class="icon" alt="add" />
|
<span>添加时段</span>
|
</button>
|
</div>
|
</div>
|
</div>
|
</div>
|
<button
|
v-if="!isLocked"
|
type="button"
|
class="addBtn"
|
@click="emit('add-strategy')"
|
>
|
<img :src="addGroupIcon" class="icon" alt="add" />
|
<span>添加运营策略组</span>
|
</button>
|
</div>
|
|
<div class="box flex flex-col gap-8px">
|
<div class="box-title">禁行区</div>
|
<div
|
v-for="sub in areaSummary.restrictedAreas"
|
:key="sub.id"
|
class="disallowed-area"
|
:class="{ active: sub.isActive }"
|
@click="emit('focus-restricted-area', sub.id)"
|
>
|
<div class="flex items-center flex-1">
|
<img
|
:src="noEntryIcon"
|
class="w-[32px] h-[32px] mr-4px"
|
alt="no-entry"
|
/>
|
<div class="flex flex-col gap-4px flex-1 w-110px">
|
<div class="name">
|
{{ sub.areaName }}
|
</div>
|
<div class="area">
|
{{
|
sub.area ? `覆盖面积:${sub.area}km²` : "绘制后计算面积"
|
}}
|
</div>
|
</div>
|
</div>
|
<div class="flex items-center gap-8px">
|
<template v-if="!isLocked">
|
<img
|
v-if="!sub.edit"
|
:src="editRestrictedIcon"
|
class="w-[16px] h-[16px] cursor-pointer"
|
alt="edit"
|
@click.stop="emit('edit-restricted-area', sub.id)"
|
/>
|
<img
|
:src="delIcon"
|
class="w-[16px] h-[16px] cursor-pointer"
|
alt="delete"
|
@click.stop="emit('remove-restricted-area', sub.id)"
|
/>
|
<n-switch
|
:value="sub.areaStatus"
|
:checked-value="1"
|
:unchecked-value="2"
|
@update:value="
|
(value) =>
|
emit('toggle-restricted-area-status', sub.id, value)
|
"
|
@click.stop
|
/>
|
</template>
|
<template v-else>
|
<div
|
:style="{
|
color:
|
Number(sub.areaStatus) === 1
|
? '#19B144'
|
: 'rgba(23, 31, 38, 0.3)',
|
}"
|
>
|
{{ Number(sub.areaStatus) === 1 ? "已启用" : "已停用" }}
|
</div>
|
</template>
|
</div>
|
</div>
|
<button
|
v-if="!isLocked"
|
type="button"
|
class="addBtn"
|
@click="emit('add-restricted-area')"
|
>
|
<img :src="addGroupIcon" class="icon" alt="add" />
|
<span>添加禁行区</span>
|
</button>
|
</div>
|
</div>
|
</n-form>
|
</div>
|
|
<div class="drawer-footer">
|
<NButton
|
v-if="showDraftButton"
|
secondary
|
:loading="saving"
|
class="w-124px"
|
@click="emit('save-draft')"
|
>
|
保存草稿
|
</NButton>
|
<NButton v-else secondary class="w-124px" @click="emit('cancel-edit')">
|
取消
|
</NButton>
|
<NButton
|
type="primary"
|
:loading="saving"
|
class="w-124px"
|
@click="emit('save-execute')"
|
>
|
发布执行
|
</NButton>
|
</div>
|
</template>
|
|
<template v-else>
|
<div ref="mainScrollRef" class="main-content">
|
<div class="main-head" :class="{ 'sticky-top': showDrawerHead }">
|
<div class="flex items-center gap-4px mb-4px">
|
<div v-if="fenceDetailData" class="title items-center">
|
{{ fenceDetailData.electronicName || "" }}
|
<NTag
|
style="font-weight: 500"
|
:type="
|
getFenceStatusTagType(Number(fenceDetailData.status || 0))
|
"
|
:bordered="false"
|
class="h-24px mx-4px"
|
>
|
{{ getFenceStatusText(Number(fenceDetailData.status || 0)) }}
|
</NTag>
|
</div>
|
<img
|
:src="closeIcon"
|
class="w-24px h-24px ml-auto cursor-pointer"
|
alt="close"
|
@click="emit('close-panel')"
|
/>
|
</div>
|
</div>
|
<div
|
v-if="fenceDetailData"
|
class="flex flex-col gap-4px px-12px pb-16px"
|
>
|
<div class="head-des">
|
围栏ID: {{ fenceDetailData.electronicNumber || "-" }}
|
</div>
|
<div class="head-des">
|
覆盖面积: {{ operationArea?.area || "-" }}km²
|
</div>
|
</div>
|
<div class="line" />
|
<div class="gap-16px flex flex-col px-12px py-16px">
|
<div class="box flex flex-col">
|
<div class="box-title mb-8px">基本信息</div>
|
<div class="box-item mb-8px">
|
<div class="label">区域类型</div>
|
<div class="value">
|
{{ regionTypeOptions[0].label }}
|
</div>
|
</div>
|
<div class="box-item">
|
<div class="label">描述</div>
|
<div class="value">
|
{{
|
operationArea?.areaDescription ||
|
fenceDetailData?.areaDescription ||
|
"-"
|
}}
|
</div>
|
</div>
|
</div>
|
|
<div class="box flex flex-col">
|
<div class="box-title mb-8px">生命周期</div>
|
<div class="box-item mb-8px">
|
<div class="label">生效时间</div>
|
<div class="value">
|
{{
|
Number(fenceDetailData?.effectType) === 1
|
? "立即生效"
|
: fenceDetailData?.effectTime
|
? dayjs(fenceDetailData.effectTime).format(
|
"YYYY-MM-DD HH:mm:ss"
|
)
|
: "-"
|
}}
|
</div>
|
</div>
|
<div class="box-item">
|
<div class="label">失效时间</div>
|
<div class="value">
|
{{
|
Number(fenceDetailData?.failureType) === 1
|
? "永不失效"
|
: fenceDetailData?.failureTime
|
? dayjs(fenceDetailData.failureTime).format(
|
"YYYY-MM-DD HH:mm:ss"
|
)
|
: "-"
|
}}
|
</div>
|
</div>
|
</div>
|
|
<div class="box flex flex-col gap-8px">
|
<div class="box-title">运营策略</div>
|
<div
|
v-for="(item, index) in strategySummary"
|
:key="item.id || index"
|
>
|
<div class="box-item">
|
<div class="label flex items-center gap-4px h-20px">
|
<div class="bold inline-block">
|
{{ index + 1 }}
|
</div>
|
运营时间
|
</div>
|
<div class="value">
|
<div>{{ item.name }}</div>
|
<div>{{ getStrategyTypeText(item) }}</div>
|
<div v-for="sub in formatStrategyTimeList(item)" :key="sub">
|
<div>{{ sub }}</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<div class="box flex flex-col gap-8px">
|
<div class="box-title">围栏内点位/场站</div>
|
<div class="fence-inner-stats">
|
<div
|
v-for="section in fenceEntityStatSections"
|
:key="section.title"
|
class="fence-inner-stats__section"
|
>
|
<div class="fence-inner-stats__content">
|
<div class="fence-inner-stats__total">
|
<div class="fence-inner-stats__title">{{ section.title }}</div>
|
<div class="fence-inner-stats__total-label">共{{ section.total }}</div>
|
</div>
|
<div class="fence-inner-stats__list">
|
<div
|
v-for="item in section.items"
|
:key="item.label"
|
class="fence-inner-stats__item"
|
>
|
<span>{{ item.label }}:</span>
|
<span>{{ item.value }}</span>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<div class="box flex flex-col gap-8px">
|
<div class="box-title">禁行区</div>
|
<div
|
v-for="sub in areaSummary.restrictedAreas"
|
:key="sub.id"
|
class="disallowed-area gap-4px"
|
:class="{ active: sub.isActive }"
|
@click="emit('focus-restricted-area', sub.id)"
|
>
|
<div class="flex items-center flex-1">
|
<img
|
:src="noEntryIcon"
|
class="w-[32px] h-[32px] mr-4px"
|
alt="no-entry"
|
/>
|
<div class="flex flex-col gap-4px flex-1">
|
<div class="name">
|
{{ sub.areaName }}
|
</div>
|
<div class="area">
|
{{ `覆盖面积:${sub.area || 0}km²` }}
|
</div>
|
</div>
|
</div>
|
<div
|
:style="{
|
color:
|
Number(sub.areaStatus) === 1
|
? '#19B144'
|
: 'rgba(23, 31, 38, 0.3)',
|
}"
|
>
|
{{ Number(sub.areaStatus) === 1 ? "已启用" : "已停用" }}
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
<div class="drawer-footer">
|
<NButton type="primary" class="w-full" @click="emit('edit-current')">
|
编辑
|
</NButton>
|
</div>
|
</template>
|
</div>
|
</template>
|
|
<style scoped lang="scss">
|
.fence-panel {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
}
|
.main-content {
|
flex: 1;
|
overflow-y: auto;
|
background-color: #fff;
|
display: flex;
|
flex-direction: column;
|
scrollbar-width: none;
|
}
|
|
.main-head {
|
padding: 16px 12px 0;
|
background: white;
|
z-index: 10;
|
|
&.sticky-top {
|
position: sticky;
|
top: 0;
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
}
|
}
|
|
.title {
|
display: flex;
|
align-items: center;
|
color: #171f26;
|
font-size: 16px;
|
font-weight: 600;
|
line-height: 24px;
|
}
|
|
.head-des {
|
color: rgba(23, 31, 38, 0.55);
|
font-size: 13px;
|
line-height: 18px;
|
}
|
|
.line {
|
width: calc(100% - 12px);
|
margin-left: 12px;
|
height: 1px;
|
border-bottom: 1px solid rgba(23, 31, 38, 0.08);
|
background: rgba(23, 31, 38, 0.08);
|
}
|
|
.box-title {
|
position: relative;
|
padding-left: 11px;
|
color: #171f26;
|
font-size: 16px;
|
font-weight: 600;
|
line-height: 24px;
|
|
&::before {
|
content: "";
|
position: absolute;
|
top: 50%;
|
left: 0;
|
transform: translateY(-50%);
|
width: 3px;
|
height: 14px;
|
border-radius: 3px;
|
background: #0068ff;
|
}
|
}
|
|
.box-item {
|
display: flex;
|
gap: 12px;
|
}
|
|
.label {
|
width: 72px;
|
flex-shrink: 0;
|
color: rgba(23, 31, 38, 0.55);
|
font-size: 13px;
|
line-height: 20px;
|
}
|
|
.value {
|
flex: 1;
|
color: #171f26;
|
font-size: 13px;
|
line-height: 20px;
|
word-break: break-all;
|
}
|
.box-item {
|
display: flex;
|
align-self: stretch;
|
|
.label {
|
width: 114px;
|
font-family: PingFang SC;
|
font-size: 13px;
|
font-weight: 600;
|
line-height: 18px;
|
letter-spacing: normal;
|
/* gray/10 */
|
color: #1D2936;
|
|
.bold {
|
/* 自动布局子元素 */
|
width: 14px;
|
height: 14px;
|
/* 自动布局 */
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
padding: 0px;
|
z-index: 0;
|
border-radius: 87.5px;
|
/* info/1 */
|
background: #A1A8AE;
|
font-family: PingFang SC;
|
font-size: 11.38px;
|
font-weight: normal;
|
line-height: 17.5px;
|
letter-spacing: normal;
|
/* white */
|
color: #FFFFFF;
|
}
|
}
|
|
.value {
|
/* 13/段落-Regular */
|
font-family: PingFang SC;
|
font-size: 13px;
|
font-weight: normal;
|
line-height: 20px;
|
letter-spacing: normal;
|
/* Text color/FONT Gy1-强调 正文标题 */
|
color: #171F26;
|
}
|
}
|
|
.group {
|
display: flex;
|
flex-direction: column;
|
padding: 12px 8px;
|
gap: 12px;
|
align-self: stretch;
|
z-index: 2;
|
border-radius: 4px;
|
background: #ffffff;
|
box-sizing: border-box;
|
border: 1px solid rgba(23, 31, 38, 0.15);
|
}
|
|
.field-block {
|
display: flex;
|
flex-direction: column;
|
gap: 8px;
|
}
|
|
.field-label {
|
color: #171f26;
|
font-size: 13px;
|
font-weight: 500;
|
line-height: 18px;
|
}
|
|
.group-header {
|
display: flex;
|
align-items: center;
|
// gap: 8px;
|
}
|
|
.group-title {
|
font-family: PingFang SC;
|
font-size: 13px;
|
font-weight: 600;
|
line-height: 18px;
|
text-align: justify;
|
letter-spacing: normal;
|
color: #171f26;
|
margin-right: 4px;
|
}
|
|
.icon {
|
width: 16px;
|
height: 16px;
|
cursor: pointer;
|
&:last-child {
|
margin-left: auto;
|
}
|
}
|
|
.btn {
|
display: flex;
|
justify-content: center;
|
padding: 4px 8px;
|
gap: 10px;
|
z-index: 0;
|
border-radius: 4px;
|
background: #ffffff;
|
box-sizing: border-box;
|
border: 1px solid rgba(23, 31, 38, 0.15);
|
cursor: pointer;
|
|
&.active {
|
background: #e6f4ff;
|
border: 1px solid #7abdff;
|
color: #0068ff;
|
}
|
}
|
|
.addBtn {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 4px;
|
height: 32px;
|
border: 1px dashed rgba(23, 31, 38, 0.15);
|
border-radius: 4px;
|
background: #fff;
|
color: #0068ff;
|
font-size: 13px;
|
line-height: 18px;
|
cursor: pointer;
|
outline: none;
|
}
|
|
.addTime {
|
width: 80px;
|
height: 28px;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
padding: 5px 4px;
|
gap: 4px;
|
z-index: 5;
|
border-radius: 4px;
|
background: #ffffff;
|
font-family: PingFang SC;
|
font-size: 13px;
|
font-weight: normal;
|
line-height: normal;
|
text-align: center;
|
letter-spacing: normal;
|
color: #171f26;
|
cursor: pointer;
|
|
.icon {
|
width: 16px;
|
height: 16px;
|
}
|
}
|
|
.time-range {
|
color: rgba(23, 31, 38, 0.55);
|
font-size: 13px;
|
}
|
|
.disallowed-area {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
// gap: 12px;
|
padding: 7px 8px;
|
border: 1px solid rgba(23, 31, 38, 0.15);
|
border-radius: 4px;
|
background: #fff;
|
cursor: pointer;
|
|
&.active {
|
border-color: #0068ff;
|
background: #e6f4ff;
|
}
|
}
|
|
.fence-inner-stats {
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
}
|
|
.fence-inner-stats__section {
|
display: block;
|
}
|
|
.fence-inner-stats__title {
|
color: #171f26;
|
font-size: 13px;
|
font-weight: 600;
|
line-height: 18px;
|
}
|
|
.fence-inner-stats__content {
|
display: flex;
|
justify-content: space-between;
|
align-items: baseline;
|
gap: 16px;
|
}
|
|
.fence-inner-stats__total {
|
min-width: 72px;
|
display: flex;
|
flex-direction: column;
|
justify-content: center;
|
gap: 4px;
|
}
|
|
.fence-inner-stats__total-label {
|
color: rgba(23, 31, 38, 0.55);
|
font-size: 13px;
|
line-height: 18px;
|
white-space: nowrap;
|
}
|
|
.fence-inner-stats__list {
|
flex: 1;
|
display: flex;
|
flex-direction: column;
|
gap: 4px;
|
min-width: 0;
|
align-items: flex-start;
|
}
|
|
.fence-inner-stats__item {
|
color: #171f26;
|
font-size: 13px;
|
line-height: 18px;
|
display: flex;
|
align-items: center;
|
justify-content: flex-start;
|
gap: 2px;
|
}
|
|
.name {
|
color: #171f26;
|
font-size: 13px;
|
font-weight: 500;
|
line-height: 20px;
|
}
|
|
.area {
|
color: rgba(23, 31, 38, 0.55);
|
font-size: 13px;
|
line-height: 18px;
|
}
|
|
.drawer-footer {
|
display: flex;
|
gap: 8px;
|
padding: 12px;
|
margin-top: auto;
|
border-top: 1px solid rgba(23, 31, 38, 0.08);
|
background: #fff;
|
}
|
|
.position-relative {
|
position: relative;
|
}
|
|
.position {
|
position: absolute;
|
inset: 0;
|
}
|
|
:deep(.n-date-picker) {
|
height: 100%;
|
}
|
</style>
|