Ver código fonte

feat:添加人员管理页面

wangsisi 5 dias atrás
pai
commit
181e80210f

+ 1 - 0
src/layout/index.vue

@@ -218,6 +218,7 @@ const go = (path) => {
 }
 
 .layout-content {
+  position: relative;
   flex: 1;
   min-height: 0;
   overflow: auto;

+ 2 - 0
src/router/routes.js

@@ -29,6 +29,8 @@ export default [
         name: "Personnel",
         component: () => import("@/views/personnel/index.vue"),
         title: "人员管理",
+        // 地图页关闭缓存,避免 keep-alive 导致容器尺寸/地图实例异常
+        keepAlive: false,
       }),
     ],
   },

+ 10 - 4
src/utils/ol-map/Map.js

@@ -53,8 +53,14 @@ class Map {
     this.initBusinessLayer();
   }
 
-  /** 与 pc-vue 一致:img_wmts_mkt + cva_wmts_mkt + 默认 XYZ */
+  /** 与 pc-vue / IDN 对齐:连续卫星底图 + WMTS + 农场 XYZ */
   async initBaseLayer(projection) {
+    // 先铺连续卫星底图(农场 lby 只覆盖局部,无此层会黑底碎块)
+    // 与 IDN-pc-vue 一致使用 ArcGIS World Imagery;放在 await 之前,避免异步等待期间黑屏
+    const worldImg =
+      "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
+    this.addXYZLayer(worldImg, { minZoom: 1, maxZoom: 19 }, 1);
+
     try {
       const getCfg = window.VE_API?.system?.getCfg;
       if (getCfg) {
@@ -74,11 +80,11 @@ class Map {
     }
 
     const xyz2 = config.base_img_url3 + "map/lby/{z}/{x}/{y}.png";
-    this.addXYZLayer(xyz2, { minZoom: 15, maxZoom: 22 });
+    this.addXYZLayer(xyz2, { minZoom: 15, maxZoom: 22 }, 3);
   }
 
-  addXYZLayer(url, options) {
-    return new XYZLayer(url, options, 3, this);
+  addXYZLayer(url, options, zIndex = 3) {
+    return new XYZLayer(url, options, zIndex, this);
   }
 
   initBusinessLayer() {

+ 303 - 0
src/views/personnel/components/AddSubjectPanel.vue

@@ -0,0 +1,303 @@
+<template>
+  <div v-if="modelValue" class="add-subject-panel">
+    <div class="panel-title">{{ isEdit ? "编辑主体信息" : "新增主体" }}</div>
+
+    <div class="form-box">
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="80px" class="subject-form">
+        <el-form-item label="类型:" prop="type">
+          <el-select v-model="form.type" placeholder="权属人/区域服务人" clearable style="width: 100%">
+            <el-option
+              v-for="item in typeOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="姓名:" prop="name">
+          <el-input v-model="form.name" placeholder="请输入姓名" clearable maxlength="20" />
+        </el-form-item>
+
+        <el-form-item label="手机号:" prop="phone">
+          <el-input v-model="form.phone" placeholder="请输入手机号" clearable maxlength="11" />
+        </el-form-item>
+
+        <el-form-item label="区域面积:" prop="areaSize">
+          <el-input v-model="form.areaSize" placeholder="自动填入" readonly />
+        </el-form-item>
+
+        <el-form-item label="执行类别:" prop="categories">
+          <el-select
+            v-model="form.categories"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            placeholder="执行类别"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in categoryOptions"
+              :key="item"
+              :label="item"
+              :value="item"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="负责农机:" prop="machines">
+          <el-select
+            v-model="form.machines"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            placeholder="请选择农机"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in machineOptions"
+              :key="item"
+              :label="item"
+              :value="item"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="常驻地址:" prop="location">
+          <template v-if="isEdit">
+            <el-input v-model="form.location" placeholder="请选择点位" readonly />
+          </template>
+          <el-button class="btn-select-point" @click="handleSelectPoint">
+            {{ isEdit ? "修改点位" : "选择点位" }}
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <div class="panel-footer">
+      <el-button class="btn-cancel" @click="handleCancel">取消</el-button>
+      <el-button class="btn-save" type="warning" :loading="saving" @click="handleSave">保存</el-button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, reactive, ref, watch } from "vue";
+import { ElMessage } from "element-plus";
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false,
+  },
+  /** add | edit */
+  mode: {
+    type: String,
+    default: "add",
+  },
+  /** 编辑时回显的人员数据 */
+  person: {
+    type: Object,
+    default: null,
+  },
+});
+
+const emit = defineEmits(["update:modelValue", "save", "cancel"]);
+
+const formRef = ref(null);
+const saving = ref(false);
+const isEdit = computed(() => props.mode === "edit");
+
+const typeOptions = [
+  { label: "权属人", value: "owner" },
+  { label: "区域服务人员", value: "staff" },
+];
+
+const categoryOptions = ["类别一", "类别二", "类别三"];
+const machineOptions = ["设备一", "设备二", "设备三", "农机一"];
+
+const createForm = () => ({
+  id: null,
+  type: "",
+  name: "",
+  phone: "",
+  areaSize: "",
+  categories: [],
+  machines: [],
+  location: "",
+  locationWkt: "",
+});
+
+const form = reactive(createForm());
+
+const rules = {
+  type: [{ required: true, message: "请选择类型", trigger: "change" }],
+  name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
+  phone: [
+    { required: true, message: "请输入手机号", trigger: "blur" },
+    { pattern: /^1\d{10}$/, message: "手机号格式不正确", trigger: "blur" },
+  ],
+};
+
+/** 将「a、b、c」转成数组并去重 */
+const splitTags = (val) => {
+  if (Array.isArray(val)) return [...val];
+  if (!val || typeof val !== "string") return [];
+  return [...new Set(val.split(/[、,,]/).map((s) => s.trim()).filter(Boolean))];
+};
+
+const fillForm = () => {
+  if (isEdit.value && props.person) {
+    const p = props.person;
+    Object.assign(form, {
+      id: p.id ?? null,
+      type: p.role || p.type || "",
+      name: p.name || "",
+      phone: p.phone || "",
+      areaSize: p.areaSize || "",
+      categories: splitTags(p.categories),
+      machines: splitTags(p.machines),
+      location: p.location || "",
+      locationWkt: p.locationWkt || "",
+    });
+  } else {
+    Object.assign(form, createForm());
+  }
+  formRef.value?.clearValidate?.();
+};
+
+watch(
+  () => [props.modelValue, props.mode, props.person],
+  ([visible]) => {
+    if (visible) fillForm();
+  }
+);
+
+const handleSelectPoint = () => {
+  ElMessage.info(isEdit.value ? "修改点位功能待接入" : "选择点位功能待接入");
+};
+
+const handleCancel = () => {
+  emit("update:modelValue", false);
+  emit("cancel");
+};
+
+const handleSave = async () => {
+  if (!formRef.value) return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    return;
+  }
+
+  saving.value = true;
+  try {
+    emit("save", {
+      mode: props.mode,
+      ...form,
+      categories: [...form.categories],
+      machines: [...form.machines],
+    });
+    emit("update:modelValue", false);
+  } finally {
+    saving.value = false;
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+$primary: $warning-color;
+
+.add-subject-panel {
+  position: absolute;
+  top: 25px;
+  right: 20px;
+  bottom: 25px;
+  z-index: 11;
+  display: flex;
+  flex-direction: column;
+  width: 380px;
+  box-sizing: border-box;
+  padding: 20px;
+  background: #fff;
+  border-radius: 10px;
+
+  .panel-title {
+    flex-shrink: 0;
+    margin-bottom: 16px;
+    font-size: 18px;
+    font-weight: 500;
+    color: #060606;
+    line-height: 1.2;
+  }
+
+  .form-box {
+    flex-shrink: 0;
+    padding: 12px;
+    border: 0.5px solid #e5e6eb;
+    border-radius: 8px;
+  }
+}
+
+.subject-form {
+  :deep(.el-form-item) {
+    margin-bottom: 16px;
+  }
+
+  :deep(.el-form-item__label) {
+    color: #333;
+    font-size: 13px;
+    padding-right: 4px;
+  }
+
+  :deep(.el-input__wrapper),
+  :deep(.el-select__wrapper) {
+    border-radius: 4px;
+  }
+
+  .btn-select-point {
+    margin-top: 0;
+    padding: 5px 16px;
+    color: $primary;
+    background: #fff;
+    border: 1px solid $primary;
+    border-radius: 4px;
+
+    &:hover,
+    &:focus {
+      color: #fff;
+      background: $primary;
+      border-color: $primary;
+    }
+  }
+}
+
+.panel-footer {
+  flex-shrink: 0;
+  margin-top: auto;
+  display: flex;
+  justify-content: flex-end;
+  gap: 16px;
+  padding-top: 20px;
+
+  .btn-cancel {
+    min-width: 72px;
+    color: #333;
+    background: #f3f3f3;
+    border: none;
+
+    &:hover,
+    &:focus {
+      color: #333;
+      background: #e8e8e8;
+      border: none;
+    }
+  }
+
+  .btn-save {
+    min-width: 72px;
+    border: none;
+    background: $primary;
+  }
+}
+</style>

+ 200 - 0
src/views/personnel/components/SignResponsibilityDialog.vue

@@ -0,0 +1,200 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="签署责任书"
+    width="540px"
+    class="sign-responsibility-dialog"
+    :close-on-click-modal="false"
+    align-center
+    append-to-body
+    destroy-on-close
+    @closed="onClosed"
+  >
+    <el-form ref="formRef" :model="form" :rules="rules" label-width="110px" class="sign-form">
+      <el-form-item label="签署日期:" prop="signDate">
+        <el-date-picker
+          v-model="form.signDate"
+          type="date"
+          placeholder="选择日期"
+          value-format="YYYY-MM-DD"
+          style="width: 100%"
+        />
+      </el-form-item>
+
+      <el-form-item label="上传责任书:" prop="fileList">
+        <el-upload
+          v-model:file-list="form.fileList"
+          class="sign-upload"
+          list-type="picture-card"
+          :auto-upload="false"
+          :limit="1"
+          accept="image/*,.pdf"
+          :on-exceed="onExceed"
+        >
+          <el-icon class="sign-upload__plus"><Plus /></el-icon>
+        </el-upload>
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="warning" :loading="submitting" @click="onSubmit">提交</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { computed, reactive, ref, watch } from "vue";
+import { Plus } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false,
+  },
+  /** 当前签署的人员信息 */
+  person: {
+    type: Object,
+    default: null,
+  },
+});
+
+const emit = defineEmits(["update:modelValue", "submit"]);
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val) => emit("update:modelValue", val),
+});
+
+const formRef = ref(null);
+const submitting = ref(false);
+
+const createForm = () => ({
+  signDate: "",
+  fileList: [],
+});
+
+const form = reactive(createForm());
+
+const rules = {
+  signDate: [{ required: true, message: "请选择签署日期", trigger: "change" }],
+  fileList: [
+    {
+      required: true,
+      validator: (_rule, value, callback) => {
+        if (!value?.length) {
+          callback(new Error("请上传责任书"));
+          return;
+        }
+        callback();
+      },
+      trigger: "change",
+    },
+  ],
+};
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) {
+      Object.assign(form, createForm());
+      formRef.value?.clearValidate?.();
+    }
+  }
+);
+
+const onExceed = () => {
+  ElMessage.warning("最多上传 1 个文件");
+};
+
+const onClosed = () => {
+  Object.assign(form, createForm());
+  formRef.value?.clearValidate?.();
+};
+
+const onSubmit = async () => {
+  if (!formRef.value) return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    return;
+  }
+
+  submitting.value = true;
+  try {
+    emit("submit", {
+      person: props.person,
+      signDate: form.signDate,
+      fileList: [...form.fileList],
+    });
+    visible.value = false;
+  } finally {
+    submitting.value = false;
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+$primary: $warning-color;
+
+.sign-form {
+  padding: 8px 12px 0;
+
+  :deep(.el-form-item__label) {
+    color: #333;
+  }
+}
+
+.sign-upload {
+  :deep(.el-upload--picture-card) {
+    width: 80px;
+    height: 80px;
+    border-radius: 4px;
+  }
+
+  :deep(.el-upload-list--picture-card .el-upload-list__item) {
+    width: 80px;
+    height: 80px;
+    border-radius: 4px;
+  }
+
+  &__plus {
+    font-size: 22px;
+    color: #909399;
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: center;
+  gap: 16px;
+  padding-bottom: 4px;
+
+  :deep(.el-button--warning) {
+    background: $primary;
+    border-color: $primary;
+  }
+}
+</style>
+
+<style lang="scss">
+.sign-responsibility-dialog {
+  .el-dialog__header {
+    margin-right: 0;
+    padding-bottom: 12px;
+    border-bottom: 1px solid #f0f0f0;
+  }
+
+  .el-dialog__body {
+    padding: 20px 20px 8px;
+  }
+
+  .el-dialog__footer {
+    padding-top: 12px;
+    border-top: 1px solid #f0f0f0;
+  }
+}
+</style>

+ 497 - 16
src/views/personnel/index.vue

@@ -1,29 +1,510 @@
 <template>
-  <div class="page-placeholder">
-    <h2>人员管理</h2>
-    <p>页面内容区域,后续在此完善业务。</p>
+  <div class="personnel-page">
+    <!-- 地图容器结构对齐 SelectAreaDialog:map-wrap > map-el -->
+    <div v-loading="mapLoading" class="map-wrap">
+      <div ref="mapRef" class="map-el"></div>
+    </div>
+
+    <div v-show="!showSubjectPanel" class="page-float">
+      <el-button class="btn-add" type="warning" @click="handleAdd">
+        + 新增主体
+      </el-button>
+    </div>
+
+    <div v-show="!showSubjectPanel" class="ownership-panel">
+      <div class="panel-header">
+        <span class="panel-title">权属列表</span>
+        <el-input v-model="keyword" class="panel-search" clearable placeholder="请搜索姓名">
+          <template #prefix>
+            <el-icon class="panel-search__icon">
+              <Search />
+            </el-icon>
+          </template>
+        </el-input>
+      </div>
+
+      <div class="panel-filters">
+        <div v-for="tab in tabs" :key="tab.value" class="panel-filters__item"
+          :class="{ 'is-active': activeTab === tab.value }" @click="activeTab = tab.value">
+          {{ tab.label }}
+        </div>
+      </div>
+
+      <div class="panel-list">
+        <div
+          v-for="item in filteredList"
+          :key="item.id"
+          class="person-card"
+          :class="{ 'is-selected': selectedId === item.id }"
+          @click="selectedId = item.id"
+        >
+          <div class="person-card__head">
+            <el-avatar :size="36" class="person-card__avatar" :src="item.avatar">
+              {{ item.name?.slice(0, 1) }}
+            </el-avatar>
+            <div class="person-card__info">
+              <div class="person-card__name-row">
+                <span class="person-card__name">{{ item.name }}</span>
+                <span class="person-card__role">{{ item.roleLabel }}</span>
+              </div>
+              <div class="person-card__phone">电话: {{ item.phone }}</div>
+            </div>
+            <el-icon class="person-card__edit" @click.stop="handleEdit(item)">
+              <Edit />
+            </el-icon>
+          </div>
+
+          <div class="person-card__body">
+            <div class="info-row">
+              <span class="info-row__label">负责区域:</span>
+              <span class="info-row__value">
+                {{ item.areas }}
+                <span class="link" @click.prevent="handleViewArea(item)">查看区域</span>
+              </span>
+            </div>
+            <div class="info-row">
+              <span class="info-row__label">区域面积:</span>
+              <span class="info-row__value">{{ item.areaSize }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-row__label">执行类别:</span>
+              <span class="info-row__value">{{ item.categories }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-row__label">负责农机:</span>
+              <span class="info-row__value">{{ item.machines }}</span>
+            </div>
+            <div class="info-row">
+              <span class="info-row__label">常驻位置:</span>
+              <span class="info-row__value">{{ item.location }}</span>
+            </div>
+          </div>
+
+          <div class="person-card__foot">
+            <div class="person-card__actions">
+              <div class="btn-action" @click="handleDownload(item)">下载责任书</div>
+              <div class="btn-action" @click.stop="handleSign(item)">签署责任书</div>
+            </div>
+            <div class="btn-action btn-permission" @click="handlePermission(item)">设置权限</div>
+          </div>
+        </div>
+
+        <el-empty v-if="!filteredList.length" description="暂无数据" :image-size="80" />
+      </div>
+    </div>
+
+    <AddSubjectPanel
+      v-model="showSubjectPanel"
+      :mode="subjectMode"
+      :person="editPerson"
+      @save="handleSubjectSave"
+    />
+    <SignResponsibilityDialog
+      v-model="showSignDialog"
+      :person="signPerson"
+      @submit="handleSignSubmit"
+    />
   </div>
 </template>
 
 <script setup>
+import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
+import { Edit, Search } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+import PersonnelMap, { DEFAULT_LOCATION } from "./map/personnelMap";
+import AddSubjectPanel from "./components/AddSubjectPanel.vue";
+import SignResponsibilityDialog from "./components/SignResponsibilityDialog.vue";
+
+const DEFAULT_FARM_ID = 101532;
+
+const { proxy } = getCurrentInstance();
+const mapRef = ref(null);
+const mapLoading = ref(false);
+let personnelMap = null;
+
+const keyword = ref("");
+const activeTab = ref("all");
+const selectedId = ref(null);
+const showSubjectPanel = ref(false);
+/** add | edit */
+const subjectMode = ref("add");
+const editPerson = ref(null);
+const showSignDialog = ref(false);
+const signPerson = ref(null);
+const tabs = [
+  { label: "全部", value: "all" },
+  { label: "权属人", value: "owner" },
+  { label: "区域服务人员", value: "staff" },
+];
+
+const list = ref([
+  {
+    id: 1,
+    name: "张扬",
+    role: "owner",
+    roleLabel: "权属人",
+    phone: "13939159356",
+    avatar: "",
+    areas: "2区、3区",
+    areaSize: "180亩",
+    categories: "类别一、类别一、类别一、类别一",
+    machines: "农机一、农机一、农机一、农机一",
+    location: "位置位置位置位置位置位置位置位置",
+  },
+  {
+    id: 2,
+    name: "张扬",
+    role: "owner",
+    roleLabel: "权属人",
+    phone: "13939159356",
+    avatar: "",
+    areas: "2区、3区",
+    areaSize: "180亩",
+    categories: "类别一、类别一、类别一、类别一",
+    machines: "农机一、农机一、农机一、农机一",
+    location: "位置位置位置位置位置位置位置位置",
+  },
+  {
+    id: 3,
+    name: "张扬",
+    role: "staff",
+    roleLabel: "区域服务人员",
+    phone: "13939159356",
+    avatar: "",
+    areas: "2区、3区",
+    areaSize: "180亩",
+    categories: "类别一、类别一、类别一、类别一",
+    machines: "农机一、农机一、农机一、农机一",
+    location: "位置位置位置位置位置位置位置位置",
+  },
+]);
+
+const filteredList = computed(() => {
+  const kw = keyword.value.trim();
+  return list.value.filter((item) => {
+    const matchTab = activeTab.value === "all" || item.role === activeTab.value;
+    const matchKw = !kw || item.name.includes(kw);
+    return matchTab && matchKw;
+  });
+});
+
+/** 等地图容器有实际宽高后再初始化(对齐弹窗 @opened 时机) */
+const waitForMapEl = () =>
+  new Promise((resolve) => {
+    const tick = (n = 0) => {
+      const el = mapRef.value;
+      if (el && el.clientWidth > 0 && el.clientHeight > 0) {
+        resolve(el);
+        return;
+      }
+      if (n > 60) {
+        resolve(el);
+        return;
+      }
+      requestAnimationFrame(() => tick(n + 1));
+    };
+    tick();
+  });
+
+/**
+ * 对齐 SelectAreaDialog.handleOpened:
+ * nextTick → init(PersonnelMap) → loadRegions → updateSize
+ */
+const handleOpened = async () => {
+  await nextTick();
+  const el = await waitForMapEl();
+  if (!el) return;
+
+  if (!personnelMap) {
+    personnelMap = new PersonnelMap();
+  }
+  await personnelMap.init(el, DEFAULT_LOCATION);
+  // 先校正尺寸,再 fit,避免容器尺寸为 0 时适配导致黑边碎块
+  personnelMap.updateSize();
+  await loadRegions();
+  personnelMap.updateSize();
+};
+
+/** 对齐 SelectAreaDialog.loadRegions */
+const loadRegions = async () => {
+  mapLoading.value = true;
+  try {
+    const res = await proxy.VE_API.farm.blueRegionList({
+      farmId: DEFAULT_FARM_ID,
+      regionId: "",
+    });
+    const regionList = Array.isArray(res?.data) ? res.data : [];
+    personnelMap?.setRegions(regionList);
+    if (!regionList.length && res?.msg) {
+      ElMessage.warning(res.msg);
+    }
+  } catch (e) {
+    console.error(e);
+    ElMessage.error("区域数据加载失败");
+  } finally {
+    mapLoading.value = false;
+    await nextTick();
+    personnelMap?.updateSize();
+    personnelMap?.fitToRegions();
+  }
+};
+
+const destroyMap = () => {
+  personnelMap?.destroy();
+  personnelMap = null;
+};
+
+const handleAdd = () => {
+  subjectMode.value = "add";
+  editPerson.value = null;
+  showSubjectPanel.value = true;
+};
+
+const handleEdit = (item) => {
+  selectedId.value = item.id;
+  subjectMode.value = "edit";
+  editPerson.value = item;
+  showSubjectPanel.value = true;
+};
+
+const handleSubjectSave = (payload) => {
+  console.log(payload.mode === "edit" ? "编辑主体" : "新增主体", payload);
+  ElMessage.success("保存成功");
+};
+
+const handleViewArea = () => { };
+const handleDownload = () => { };
+
+const handleSign = (item) => {
+  signPerson.value = item;
+  showSignDialog.value = true;
+};
+
+const handleSignSubmit = (payload) => {
+  console.log("签署责任书", payload);
+  ElMessage.success("提交成功");
+};
+
+const handlePermission = () => { };
+
+onMounted(() => {
+  handleOpened();
+});
+
+onBeforeUnmount(() => {
+  destroyMap();
+});
 </script>
 
 <style lang="scss" scoped>
-.page-placeholder {
-  box-sizing: border-box;
-  min-height: 100%;
-  padding: 24px;
-  background: #fff;
-
-  h2 {
-    margin: 0 0 8px;
-    font-size: 20px;
-    color: #303133;
+$primary: $warning-color;
+
+.personnel-page {
+  position: absolute;
+  inset: 0;
+  overflow: hidden;
+  background: #1a1a1a;
+
+  .map-wrap {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    background: #1a1a1a;
+    overflow: hidden;
+
+    .map-el {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  .page-float {
+    position: absolute;
+    top: 16px;
+    left: 16px;
+    z-index: 10;
+
+    .btn-add {
+      height: 36px;
+      padding: 0 16px;
+      border: none;
+      border-radius: 4px;
+      font-size: 14px;
+      background: $primary;
+    }
   }
 
-  p {
-    margin: 0;
-    color: #909399;
+  .ownership-panel {
+    position: absolute;
+    top: 25px;
+    right: 20px;
+    bottom: 25px;
+    z-index: 10;
+    display: flex;
+    flex-direction: column;
+    width: 380px;
+    box-sizing: border-box;
+    padding: 20px;
+    background: #fff;
+    border-radius: 10px;
+
+    .panel-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+
+      .panel-title {
+        font-size: 18px;
+        font-weight: 500;
+        color: #060606;
+      }
+
+      .panel-search {
+        width: 150px;
+      }
+    }
+
+    .panel-filters {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin: 10px 0;
+
+      &__item {
+        padding: 5px 10px;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        font-size: 12px;
+        color: rgba(0, 0, 0, 0.6);
+        background: #F3F3F3;
+        border: 1px solid transparent;
+        border-radius: 4px;
+        cursor: pointer;
+
+        &.is-active {
+          color: $primary;
+          background: rgba($primary, 0.1);
+          border-color: $primary;
+        }
+      }
+    }
+
+    .panel-list {
+      overflow: auto;
+
+      .person-card {
+        padding: 12px;
+        border: .5px solid #E5E6EB;
+        border-radius: 8px;
+        cursor: pointer;
+        border: 1px solid transparent;
+
+        &.is-selected {
+          border: 1px solid $primary;
+          box-shadow: 0 4px 6px rgba($primary, 0.2) inset;
+        }
+
+        &__head {
+          display: flex;
+          align-items: center;
+          gap: 10px;
+          padding-bottom: 10px;
+          border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+          margin-bottom: 10px;
+        }
+
+        &__info {
+          flex: 1;
+        }
+
+        &__name-row {
+          display: flex;
+          align-items: center;
+          gap: 7px;
+        }
+
+        &__name {
+          font-weight: 500;
+          color: #060606;
+        }
+
+        &__role {
+          padding: 1px 5px;
+          font-size: 12px;
+          color: #070707;
+          background: rgba(100, 100, 100, 0.1);
+          border-radius: 2px;
+        }
+
+        &__phone {
+          margin-top: 4px;
+          font-size: 12px;
+          color: rgba(105, 105, 105, 0.5);
+        }
+
+        &__edit {
+          font-size: 20px;
+          color: $primary;
+        }
+
+        &__foot {
+          display: flex;
+          align-items: center;
+          justify-content: space-between;
+          gap: 6px;
+          margin-top: 10px;
+        }
+
+        &__actions {
+          display: flex;
+          align-items: center;
+          gap: 6px;
+        }
+      }
+
+      .info-row {
+        display: flex;
+        margin-top: 6px;
+        font-size: 13px;
+
+        &__label {
+          width: 66px;
+          color: #6C6C6C;
+        }
+
+        &__value {
+          flex: 1;
+          color: #000;
+        }
+
+        .link {
+          color: $primary;
+          text-decoration: underline;
+          cursor: pointer;
+          margin-left: 6px;
+        }
+      }
+
+      .btn-action {
+        padding: 6px 10px;
+        cursor: pointer;
+        border-radius: 4px;
+        color: $primary;
+        background: rgba($primary, 0.12);
+        font-size: 12px;
+      }
+
+      .btn-permission {
+        color: #fff;
+        background: #FB8E33;
+      }
+
+      .person-card+.person-card {
+        margin-top: 10px;
+      }
+    }
   }
 }
 </style>

+ 109 - 0
src/views/personnel/map/personnelMap.js

@@ -0,0 +1,109 @@
+import * as KMap from "@/utils/ol-map/KMap";
+import config from "@/api/config";
+import { wktCastGeom, newAreaFeature } from "@/common/ol_common";
+
+/** 与设备选区地图默认中心一致 */
+export const DEFAULT_LOCATION = "POINT(113.61448114737868 23.585550924763083)";
+
+/** 右侧权属列表面板宽度,fit 时预留 */
+const PANEL_FIT_PADDING = [40, 460, 40, 40];
+
+/**
+ * 人员管理页地图
+ * 创建方式对齐 device/areaSelectMap:new KMap.Map(...)
+ */
+export default class PersonnelMap {
+  constructor() {
+    const vectorStyle = new KMap.VectorStyle();
+    this.vectorStyle = vectorStyle;
+    this.kmap = null;
+
+    this.blueRegionLayer = new KMap.VectorLayer("blueRegionLayer", 99999, {
+      minZoom: 1,
+      maxZoom: 22,
+      style: () =>
+        vectorStyle.getPolygonStyle(
+          "rgba(0, 0, 0, 0.45)",
+          "rgba(255, 212, 137, 0.85)",
+          1.5
+        ),
+    });
+  }
+
+  /**
+   * @param {HTMLElement} target
+   * @param {string} location WKT
+   */
+  init(target, location = DEFAULT_LOCATION) {
+    this.destroy();
+
+    const level = 16;
+    const coordinate = wktCastGeom(location).getFirstCoordinate();
+
+    this.kmap = new KMap.Map(
+      target,
+      level,
+      coordinate[0],
+      coordinate[1],
+      null,
+      8,
+      22,
+      "vec",
+      true,
+      true
+    );
+
+    this.kmap.addLayer(this.blueRegionLayer.layer);
+
+    const xyz = config.base_img_url + "map/lby/{z}/{x}/{y}.png";
+    this.kmap.addXYZLayer(xyz, { minZoom: 8, maxZoom: 22 }, 2);
+
+    setTimeout(() => this.updateSize(), 80);
+  }
+
+  /**
+   * @param {Array} list 蓝区列表
+   */
+  setRegions(list = []) {
+    if (!this.blueRegionLayer?.source) return;
+    this.blueRegionLayer.source.clear();
+
+    for (const item of list) {
+      const row = {
+        ...item,
+        wkt: item.geom || item.wkt,
+        id: item.blueZoneCode ?? item.id,
+        name:
+          item.name ||
+          item.regionName ||
+          item.blueZoneName ||
+          String(item.blueZoneCode ?? item.id),
+      };
+      if (!row.wkt) continue;
+
+      const feature = newAreaFeature({ ...row, geom: row.wkt });
+      feature.set("name", row.name);
+      this.blueRegionLayer.addFeature(feature);
+    }
+
+    this.fitToRegions();
+  }
+
+  fitToRegions(padding = PANEL_FIT_PADDING) {
+    const extent = this.blueRegionLayer?.source?.getExtent?.();
+    if (!extent || !isFinite(extent[0]) || !this.kmap) return;
+    this.kmap.fit(extent, { padding, duration: 300, maxZoom: 18 });
+  }
+
+  updateSize() {
+    this.kmap?.updateSize?.() || this.kmap?.map?.updateSize?.();
+  }
+
+  destroy() {
+    if (this.blueRegionLayer?.source) {
+      this.blueRegionLayer.source.clear();
+    }
+    this.kmap?.destroy?.();
+    this.kmap = null;
+  }
+}