Ver código fonte

fix: 表格内容和编辑

lxf 1 mês atrás
pai
commit
6168ed81c9

+ 114 - 39
src/layout/index.vue

@@ -1,24 +1,37 @@
 <template>
   <div class="layout">
-    <aside class="layout-aside">
+    <aside class="layout-aside" :class="{ 'is-collapse': isCollapse }">
       <div class="brand">
         <img class="brand-logo" :src="logo" alt="logo">
-        <span class="brand-title">飞鸟后台数据平台</span>
+        <span v-show="!isCollapse" class="brand-title">飞鸟后台数据平台</span>
       </div>
 
-      <nav class="side-menu">
-        <div v-for="item in sideMenus" :key="item.path" class="side-menu__item"
-          :class="{ 'is-active': isActive(item.path) }" @click="go(item.path)">
+      <el-menu
+        :default-active="activeMenu"
+        :collapse="isCollapse"
+        :collapse-transition="false"
+        class="side-menu"
+        @select="go"
+      >
+        <el-menu-item v-for="item in sideMenus" :key="item.path" :index="item.path">
           <span class="side-menu__icon">
             <MenuIcon :name="item.icon" />
           </span>
-          <span class="side-menu__text">{{ item.title }}</span>
-        </div>
-      </nav>
+          <template #title>
+            <span class="side-menu__text">{{ item.title }}</span>
+          </template>
+        </el-menu-item>
+      </el-menu>
     </aside>
 
     <div class="layout-main">
       <div class="layout-header">
+        <div class="layout-header__collapse" @click="toggleCollapse">
+          <el-icon :size="20">
+            <Expand v-if="isCollapse" />
+            <Fold v-else />
+          </el-icon>
+        </div>
         <div class="header-right">
           <el-dropdown trigger="click">
             <div class="user-entry">
@@ -55,8 +68,9 @@
 </template>
 
 <script setup>
+import { computed, ref } from "vue";
 import { useRoute, useRouter } from "vue-router";
-import { UserFilled, ArrowDown } from "@element-plus/icons-vue";
+import { UserFilled, ArrowDown, Fold, Expand } from "@element-plus/icons-vue";
 import { sideMenus } from "./menu";
 import MenuIcon from "./MenuIcon.vue";
 
@@ -64,8 +78,18 @@ import logo from "@/assets/layout/logo.png";
 
 const route = useRoute();
 const router = useRouter();
+const isCollapse = ref(false);
 
-const isActive = (path) => route.path === path || route.path.startsWith(path + "/");
+const activeMenu = computed(() => {
+  const matched = sideMenus.find(
+    (item) => route.path === item.path || route.path.startsWith(item.path + "/")
+  );
+  return matched?.path || route.path;
+});
+
+const toggleCollapse = () => {
+  isCollapse.value = !isCollapse.value;
+};
 
 const go = (path) => {
   if (route.path !== path) {
@@ -90,6 +114,12 @@ const go = (path) => {
   display: flex;
   flex-direction: column;
   color: rgba(255, 255, 255, 0.6);
+  transition: width 0.2s ease;
+  overflow: hidden;
+
+  &.is-collapse {
+    width: 64px;
+  }
 }
 
 .brand {
@@ -100,12 +130,19 @@ const go = (path) => {
   padding: 0 16px;
   background: url('@/assets/layout/title-bg.png') no-repeat center center;
   background-size: 100% 100%;
+  flex-shrink: 0;
+}
+
+.layout-aside.is-collapse .brand {
+  justify-content: center;
+  padding: 0;
 }
 
 .brand-logo {
   width: 29px;
   height: 32px;
   object-fit: contain;
+  flex-shrink: 0;
 }
 
 .brand-title {
@@ -113,43 +150,63 @@ const go = (path) => {
   letter-spacing: 2px;
   font-family: "PangMenZhengDao";
   background: linear-gradient(180deg, #FFD489 0%, #FFFFFF 100%);
-  // background: linear-gradient(to bottom, #F3C11D, #ffffff);
   background-clip: text;
   color: transparent;
   text-decoration: none;
+  white-space: nowrap;
 }
 
 .side-menu {
-  padding: 13px 0 12px 20px;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
+  flex: 1;
+  border-right: none !important;
+  background: transparent !important;
+  padding: 13px 0 12px;
 
-.side-menu__item {
-  height: 60px;
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 0 16px;
-  border-radius: 4px 0 0 4px;
-  cursor: pointer;
-  color: #9a9a9a;
-  transition: background 0.2s, color 0.2s;
+  &:not(.el-menu--collapse) {
+    width: 100%;
+    padding-left: 20px;
+  }
 
-  &:hover {
-    color: #FFD489;
+  :deep(.el-menu-item) {
+    height: 60px;
+    line-height: 60px;
+    margin: 0;
+    padding: 0 16px !important;
+    border-radius: 4px 0 0 4px;
+    color: #9a9a9a;
+    background: transparent !important;
+
+    &:hover {
+      color: #FFD489 !important;
+      background: transparent !important;
+    }
+
+    &.is-active {
+      font-weight: 500;
+      color: #FFD489 !important;
+      background: linear-gradient(90deg, rgba(255, 212, 137, 0.18) 0%, rgba(25, 25, 25, 0) 100%) !important;
+    }
   }
 
-  &.is-active {
-    font-weight: 500;
-    color: #FFD489;
-    background: linear-gradient(90deg, rgba(255, 212, 137, 0.18) 0%, rgba(25, 25, 25, 0) 100%);
+  &.el-menu--collapse {
+    width: 64px;
+
+    :deep(.el-menu-item) {
+      justify-content: center;
+      padding: 0 !important;
+      border-radius: 0;
+    }
   }
 }
 
 .side-menu__icon {
   display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  margin-right: 12px;
+  flex-shrink: 0;
 
   :deep(.menu-icon) {
     filter: grayscale(1);
@@ -157,11 +214,13 @@ const go = (path) => {
   }
 }
 
-.side-menu__item.is-active .side-menu__icon,
-.side-menu__item:hover .side-menu__icon {
-  :deep(.menu-icon) {
-    filter: none;
-  }
+.side-menu.el-menu--collapse .side-menu__icon {
+  margin-right: 0;
+}
+
+.side-menu :deep(.el-menu-item.is-active .menu-icon),
+.side-menu :deep(.el-menu-item:hover .menu-icon) {
+  filter: none;
 }
 
 .side-menu__text {
@@ -181,14 +240,30 @@ const go = (path) => {
   height: 76px;
   flex-shrink: 0;
   background: #fff;
-  // box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.1);
   box-shadow: 0px -2px 2px 0px rgba(0, 0, 0, 0.1) inset;
   display: flex;
   align-items: center;
-  justify-content: flex-end;
+  justify-content: space-between;
   padding: 0 24px;
 }
 
+.layout-header__collapse {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  color: #666;
+  cursor: pointer;
+  border-radius: 4px;
+  transition: color 0.2s, background 0.2s;
+
+  &:hover {
+    color: #333;
+    background: #f5f5f5;
+  }
+}
+
 .header-right {
   display: flex;
   align-items: center;

+ 230 - 80
src/views/device/DeviceDetailDialog.vue

@@ -1,8 +1,8 @@
 <template>
   <el-dialog
     :model-value="modelValue"
-    title="设备详情"
-    width="520px"
+    title="人员详情"
+    width="650px"
     align-center
     append-to-body
     destroy-on-close
@@ -10,56 +10,72 @@
     @update:model-value="emit('update:modelValue', $event)"
   >
     <div v-if="data" class="detail-body">
-      <div class="detail-row">
-        <span class="detail-label">农机名称:</span>
-        <span class="detail-value">{{ data.name || "-" }}</span>
+      <div class="detail-line">
+        <span class="detail-label">姓名/手机号:</span>
+        <span class="detail-value">{{ displayName }} {{ displayPhone }}</span>
       </div>
 
-      <div class="detail-row">
-        <span class="detail-label">农机类型:</span>
-        <div class="detail-value">
-          <span v-if="data.type" class="tag">{{ data.type }}</span>
-          <span v-else>-</span>
-        </div>
-      </div>
+      <div class="detail-section">
+        <div class="detail-label detail-label--block">种植品种:</div>
 
-      <div class="detail-row">
-        <span class="detail-label">农事类别:</span>
-        <div class="detail-value tags">
-          <span
-            v-for="(item, idx) in data.categories || []"
-            :key="idx"
-            class="tag"
+        <div v-if="categoryGroups.length" class="category-list">
+          <div
+            v-for="(group, gIdx) in categoryGroups"
+            :key="gIdx"
+            class="category-block"
           >
-            {{ item }}
-          </span>
-          <span v-if="!(data.categories && data.categories.length)">-</span>
-        </div>
-      </div>
+            <span class="category-tag">品类:{{ group.category }}</span>
 
-      <div class="detail-row">
-        <span class="detail-label">存放点位:</span>
-        <span class="detail-value">{{ data.location || "-" }}</span>
-      </div>
-
-      <div class="detail-row detail-row--top">
-        <span class="detail-label">执行区域:</span>
-        <div class="detail-value">
-          <span v-if="data.areaLabel" class="area-box">
-            <div class="area-text">{{ data.areaLabel }}</div>
-          </span>
-          <span v-else>-</span>
+            <div class="planting-grid">
+              <div
+                v-for="(item, idx) in group.plantings"
+                :key="idx"
+                class="planting-card"
+              >
+                <span class="variety-tag">{{ item.variety || "-" }}</span>
+                <div class="planting-card__row">
+                  <span class="planting-card__label">种植面积:</span>
+                  <span>{{ item.area || "-" }}</span>
+                </div>
+                <div class="planting-card__row">
+                  <span class="planting-card__label">起种点:</span>
+                  <span class="start-point-tag">{{ item.startPoint || "-" }}</span>
+                  <span class="start-point-date">{{ item.startDate || "" }}</span>
+                </div>
+                <div class="planting-card__row">
+                  <span class="planting-card__label">种植点位:</span>
+                  <span class="location-text" :title="item.plantLocation">
+                    {{ item.plantLocation || "-" }}
+                  </span>
+                  <el-button
+                    link
+                    type="warning"
+                    class="link-btn"
+                    @click="handleViewPlantLocation(item)"
+                  >
+                    查看点位
+                  </el-button>
+                </div>
+              </div>
+            </div>
+          </div>
         </div>
+        <div v-else class="detail-empty">-</div>
       </div>
 
-      <div class="detail-row">
-        <span class="detail-label">负责人:</span>
-        <span class="detail-value">{{ data.ownerName || "-" }}</span>
-      </div>
-
-      <div class="detail-row">
-        <span class="detail-label">手机号:</span>
-        <span class="detail-value">{{ data.ownerPhone || "-" }}</span>
+      <div class="detail-line detail-line--footer">
+        <span class="detail-label">常驻点位:</span>
+        <span class="detail-value location-text" :title="displayResident">
+          {{ displayResident }}
+        </span>
+        <el-button
+          link
+          type="warning"
+          class="link-btn"
+          @click="handleViewResidentLocation"
+        >
+          查看点位
+        </el-button>
       </div>
     </div>
 
@@ -72,7 +88,10 @@
 </template>
 
 <script setup>
-defineProps({
+import { computed } from "vue";
+import { ElMessage } from "element-plus";
+
+const props = defineProps({
   modelValue: {
     type: Boolean,
     default: false,
@@ -85,6 +104,54 @@ defineProps({
 
 const emit = defineEmits(["update:modelValue", "edit"]);
 
+const displayName = computed(
+  () => props.data?.name || props.data?.ownerName || "-"
+);
+const displayPhone = computed(
+  () => props.data?.phone || props.data?.ownerPhone || ""
+);
+const displayResident = computed(
+  () => props.data?.residentLocation || props.data?.location || "-"
+);
+
+/** 按品类分组展示;兼容 categories 数组或单品类 + plantings */
+const categoryGroups = computed(() => {
+  const row = props.data;
+  if (!row) return [];
+
+  if (Array.isArray(row.categories) && row.categories.length) {
+    return row.categories
+      .map((c) => {
+        if (typeof c === "string") {
+          return { category: c, plantings: row.plantings || [] };
+        }
+        return {
+          category: c.category || c.name || c.cropCategory || "-",
+          plantings: c.plantings || [],
+        };
+      })
+      .filter((g) => g.plantings.length);
+  }
+
+  const plantings = row.plantings || [];
+  if (!plantings.length) return [];
+
+  return [
+    {
+      category: row.cropCategory || row.type || "-",
+      plantings,
+    },
+  ];
+});
+
+const handleViewPlantLocation = () => {
+  ElMessage.info("查看种植点位");
+};
+
+const handleViewResidentLocation = () => {
+  ElMessage.info("查看常驻点位");
+};
+
 const handleEdit = () => {
   emit("edit");
 };
@@ -92,63 +159,153 @@ const handleEdit = () => {
 
 <style lang="scss" scoped>
 .detail-body {
-  padding: 20px 28px 0 16px;
-  border-top: 1px solid #E5E6EB;
+  padding: 24px 14px 8px;
+  border-top: 1px solid #e5e6eb;
 }
 
-.detail-row {
+.detail-line {
   display: flex;
   align-items: center;
+  flex-wrap: wrap;
+  gap: 4px 0;
   min-height: 32px;
-  margin-bottom: 12px;
+  margin-bottom: 16px;
+  font-size: 14px;
+  line-height: 22px;
+  color: #1d2129;
 
-  &--top {
-    align-items: flex-start;
+  &--footer {
+    margin-top: 8px;
+    margin-bottom: 0;
   }
 }
 
+.detail-section {
+  margin-bottom: 16px;
+}
+
 .detail-label {
   flex-shrink: 0;
-  width: 70px;
-  text-align: right;
-  color: #1D2129;
+  color: #1d2129;
   font-size: 14px;
   line-height: 22px;
+
+  &--block {
+    margin-bottom: 10px;
+  }
 }
 
 .detail-value {
-  flex: 1;
-  margin-left: 12px;
-  color: #000;
+  color: #1d2129;
   font-size: 14px;
   line-height: 22px;
   word-break: break-all;
 }
 
-.tags {
+.detail-empty {
+  color: #86909c;
+  font-size: 14px;
+}
+
+.category-list {
   display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
+  flex-direction: column;
+  gap: 16px;
 }
 
-.tag {
+.category-block {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.category-tag {
+  display: inline-flex;
+  align-self: flex-start;
+  padding: 0 10px;
+  border-radius: 2px;
+  min-width: 130px;
+  background: linear-gradient(270deg, #FFFFFF 0%, #FFEAD8 100%);
+  color: $warning-color;
+  font-size: 14px;
+  font-weight: 500;
+  height: 26px;
+  line-height: 26px;
+  box-sizing: border-box;
+}
+
+.planting-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.planting-card {
+  box-sizing: border-box;
+  padding: 10px;
+  border: 1px solid #E5E6EB;
+  border-radius: 8px;
+  background: #fff;
+}
+
+.variety-tag {
   display: inline-block;
-  padding: 1px 10px;
-  border-radius: 4px;
-  background: rgba(251, 142, 51, 0.1);
-  color: #FB8E33;
+  margin-bottom: 10px;
+  padding: 1px 6px;
+  border-radius: 2px;
+  background: rgba(120, 120, 120, 0.1);
+  color: #4C4C4C;
   font-size: 12px;
+  line-height: 20px;
+}
+
+.planting-card__row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-bottom: 8px;
+  color: #1d2129;
+  font-size: 13px;
   line-height: 22px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.planting-card__label {
+  flex-shrink: 0;
+  color: #1d2129;
 }
 
-.area-box {
+.start-point-tag {
+  display: inline-block;
+  padding: 1px 8px;
+  border-radius: 2px;
+  background: #f2f3f5;
+  color: #4e5969;
+  font-size: 12px;
+  line-height: 20px;
 }
 
-.area-thumb {
-  display: block;
-  width: 100%;
-  height: 96px;
-  object-fit: cover;
+.start-point-date {
+  color: #1d2129;
+  font-size: 13px;
+}
+
+.location-text {
+  max-width: 160px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: #1d2129;
+}
+
+.link-btn {
+  padding: 0;
+  height: auto;
+  font-size: 13px;
 }
 
 .dialog-footer {
@@ -160,16 +317,9 @@ const handleEdit = () => {
 .btn-edit {
   min-width: 120px;
   height: 36px;
-  color: $base-color;
-  background: #fff;
+  color: #fff;
   border-color: $base-color;
+  background: $base-color;
   border-radius: 4px;
-
-  &:hover,
-  &:focus {
-    color: #fff;
-    background: $base-color;
-    border-color: $base-color;
-  }
 }
 </style>

+ 454 - 199
src/views/device/DeviceFormDialog.vue

@@ -1,103 +1,107 @@
 <template>
-  <el-dialog
-    :model-value="modelValue"
-    :title="isEdit ? '编辑设备' : '新增设备'"
-    width="520px"
-    align-center
-    destroy-on-close
-    class="device-form-dialog"
-    @update:model-value="emit('update:modelValue', $event)"
-    @closed="handleClosed"
-  >
-    <el-form
-      ref="formRef"
-      :model="form"
-      :rules="rules"
-      label-width="96px"
-      label-position="right"
-      class="device-form"
-    >
-      <el-form-item label="农机名称" prop="name">
-        <el-input
-          v-model="form.name"
-          :placeholder="isEdit ? '' : '请输入农机'"
-          maxlength="50"
-        />
+  <el-dialog :model-value="modelValue" :title="isEdit ? '编辑' : '新增'" width="780px" align-center destroy-on-close
+    class="device-form-dialog" @update:model-value="emit('update:modelValue', $event)" @closed="handleClosed">
+    <el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="device-form">
+      <el-form-item label="姓名/手机号:" prop="ownerName" class="form-item--inline">
+        <div class="name-phone-row">
+          <el-input v-model="form.ownerName" placeholder="请输入姓名" maxlength="20" class="name-phone-row__name" />
+          <el-form-item prop="ownerPhone" class="name-phone-row__phone-item">
+            <el-input v-model="form.ownerPhone" placeholder="请输入手机号" maxlength="11" />
+          </el-form-item>
+        </div>
       </el-form-item>
 
-      <el-form-item label="农机类型" prop="type">
-        <el-select
-          v-model="form.type"
-          placeholder="请选择农机类型"
-          style="width: 100%"
-        >
-          <el-option
-            v-for="item in typeOptions"
-            :key="item"
-            :label="item"
-            :value="item"
-          />
-        </el-select>
-      </el-form-item>
+      <div class="form-section">
 
-      <el-form-item label="农事类别" prop="categories">
-        <el-select
-          v-model="form.categories"
-          multiple
-          collapse-tags
-          collapse-tags-tooltip
-          :max-collapse-tags="3"
-          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="location" required>
-        <div class="field-block">
-          <el-input
-            v-if="isEdit || form.location"
-            v-model="form.location"
-            placeholder="请选择存放点位"
-            readonly
-          />
-          <el-button class="btn-outline" @click="handleSelectLocation">
-            {{ isEdit || form.location ? "修改点位" : "选择点位" }}
+        <div class="form-section__head">
+          <div class="form-section__label">
+            种植品种:
+          </div>
+          <el-button type="warning" @click="addCategory">
+            + 添加品类
           </el-button>
         </div>
-      </el-form-item>
 
-      <el-form-item label="执行区域" prop="areaLabel" required>
-        <div class="field-block">
-          <div v-if="isEdit && form.areaLabel" class="area-preview">
-            {{ form.areaLabel }}
+        <div class="category-list">
+          <div v-for="(group, gIdx) in form.categories" :key="group._key" class="category-block">
+            <div class="category-block__head">
+              <el-select v-model="group.category" placeholder="请选择品类" class="category-select">
+                <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
+              </el-select>
+
+              
+            <div class="link-btn secondary-link" @click="addPlanting(gIdx)">
+              + 添加品种
+            </div>
+
+              <el-button v-if="form.categories.length > 1" link type="danger" class="link-btn"
+                @click="removeCategory(gIdx)">
+                删除品类
+              </el-button>
+            </div>
+
+            <div class="planting-grid">
+              <div v-for="(item, pIdx) in group.plantings" :key="item._key" class="planting-card">
+                <div class="planting-card__top">
+                  <el-select v-model="item.variety" placeholder="请选择品种" class="variety-select">
+                    <el-option v-for="v in varietyOptions" :key="v" :label="v" :value="v" />
+                  </el-select>
+                  <el-button link type="danger" class="link-btn"
+                    :disabled="group.plantings.length <= 1 && form.categories.length <= 1"
+                    @click="removePlanting(gIdx, pIdx)">
+                    删除
+                  </el-button>
+                </div>
+
+                <div class="planting-card__row">
+                  <span class="planting-card__label">物候期:</span>
+                  <el-select v-model="item.phenology" placeholder="请选择物候期" class="planting-card__input">
+                    <el-option v-for="ph in phenologyOptions" :key="ph" :label="ph" :value="ph" />
+                  </el-select>
+                </div>
+
+                <div class="planting-card__row">
+                  <span class="planting-card__label">种植面积:</span>
+                  <el-input v-model="item.area" placeholder="请输入种植面积" class="planting-card__input" />
+                </div>
+
+                <div class="planting-card__row">
+                  <span class="planting-card__label">起种点:</span>
+                  <el-select v-model="item.startPoint" placeholder="请选择" class="start-point-select">
+                    <el-option v-for="sp in startPointOptions" :key="sp" :label="sp" :value="sp" />
+                  </el-select>
+                  <el-date-picker v-model="item.startDate" type="date" placeholder="选择日期" value-format="YYYY.MM.DD"
+                    format="YYYY.MM.DD" class="start-date-picker" />
+                </div>
+
+                <div class="planting-card__row">
+                  <span class="planting-card__label">种植点位:</span>
+                  <div class="location-box">
+                    <span class="location-box__text" :title="item.plantLocation">
+                      {{ item.plantLocation || "请选择点位" }}
+                    </span>
+                    <el-button link type="warning" class="link-btn" @click="openPlantLocation(gIdx, pIdx)">
+                      修改点位
+                    </el-button>
+                  </div>
+                </div>
+              </div>
+            </div>
+
           </div>
-          <el-button class="btn-outline" @click="handleSelectArea">
-            {{ isEdit ? "修改区域" : "选择区域" }}
-          </el-button>
         </div>
-      </el-form-item>
 
-      <el-form-item label="负责人" prop="ownerName">
-        <el-input
-          v-model="form.ownerName"
-          :placeholder="isEdit ? '' : '请输入姓名'"
-          maxlength="20"
-        />
-      </el-form-item>
+      </div>
 
-      <el-form-item label="手机号" prop="ownerPhone">
-        <el-input
-          v-model="form.ownerPhone"
-          :placeholder="isEdit ? '' : '请输入手机号'"
-          maxlength="11"
-        />
+      <el-form-item label="常驻点位:" prop="residentLocation" class="form-item--resident">
+        <div class="location-box location-box--full">
+          <span class="location-box__text" :title="form.residentLocation">
+            {{ form.residentLocation || "请选择点位" }}
+          </span>
+          <el-button link type="warning" class="link-btn" @click="openResidentLocation">
+            修改点位
+          </el-button>
+        </div>
       </el-form-item>
     </el-form>
 
@@ -111,25 +115,13 @@
     </template>
   </el-dialog>
 
-  <SelectAreaDialog
-    v-model="areaDialogVisible"
-    :farm-id="101532"
-    :selected-ids="form.selectedAreaIds"
-    @confirm="handleAreaConfirm"
-  />
-
-  <SelectLocationDialog
-    v-model="locationDialogVisible"
-    :farm-id="101532"
-    :location-wkt="form.locationWkt"
-    @confirm="handleLocationConfirm"
-  />
+  <SelectLocationDialog v-model="locationDialogVisible" :farm-id="101532" :location-wkt="activeLocationWkt"
+    @confirm="handleLocationConfirm" />
 </template>
 
 <script setup>
 import { computed, reactive, ref, watch } from "vue";
 import { ElMessage } from "element-plus";
-import SelectAreaDialog from "./SelectAreaDialog.vue";
 import SelectLocationDialog from "./SelectLocationDialog.vue";
 
 const props = defineProps({
@@ -152,82 +144,110 @@ const emit = defineEmits(["update:modelValue", "submit"]);
 const isEdit = computed(() => props.mode === "edit");
 const formRef = ref();
 const submitting = ref(false);
-const areaDialogVisible = ref(false);
 const locationDialogVisible = ref(false);
+/** @type {import('vue').Ref<null | { type: 'resident' } | { type: 'plant', categoryIndex: number, plantingIndex: number }>} */
+const locationTarget = ref(null);
+
+const categoryOptions = ["品类一", "品类二", "荔枝", "龙眼"];
+const varietyOptions = ["品种一", "妃子笑", "桂味", "糯米糍", "白糖罂", "仙进奉"];
+const phenologyOptions = ["生育期一", "生育期二", "生育期三", "生育期四"];
+const startPointOptions = ["来白点", "来白点2"];
+
+let keySeed = 0;
+const nextKey = () => `k-${++keySeed}`;
+
+const createPlanting = (data = {}) => ({
+  _key: nextKey(),
+  variety: data.variety || "",
+  phenology: data.phenology || "生育期一",
+  area: data.area || "",
+  startPoint: data.startPoint || "",
+  startDate: data.startDate || "",
+  plantLocation: data.plantLocation || "",
+  plantLocationWkt: data.plantLocationWkt || "",
+});
 
-const typeOptions = ["肥水一体", "水肥一体", "植保无人机", "收割机", "播种机"];
-const categoryOptions = ["类别一", "类别二", "类别三", "类别四", "类别五", "类别六", "类别七"];
+const createCategory = (data = {}) => ({
+  _key: nextKey(),
+  category: data.category || data.cropCategory || "",
+  plantings:
+    Array.isArray(data.plantings) && data.plantings.length
+      ? data.plantings.map((p) => createPlanting(p))
+      : [createPlanting()],
+});
 
 const createEmptyForm = () => ({
   id: null,
-  name: "",
-  type: "",
-  categories: [],
-  location: "",
-  locationWkt: "",
-  areaLabel: "",
-  selectedAreaIds: [],
-  selectedAreas: [],
   ownerName: "",
   ownerPhone: "",
+  residentLocation: "",
+  locationWkt: "",
+  categories: [createCategory({ category: "品类一" })],
 });
 
 const form = reactive(createEmptyForm());
 
 const rules = {
-  name: [{ required: true, message: "请输入农机名称", trigger: "blur" }],
-  type: [{ required: true, message: "请选择农机类型", trigger: "change" }],
-  categories: [
-    {
-      type: "array",
-      required: true,
-      min: 1,
-      message: "请选择农事类别",
-      trigger: "change",
-    },
-  ],
-  location: [
+  ownerName: [{ required: true, message: "请输入姓名", trigger: "blur" }],
+  ownerPhone: [
+    { required: true, message: "请输入手机号", trigger: "blur" },
     {
-      validator: (_rule, _value, callback) => {
-        if (!form.location) callback(new Error("请选择存放点位"));
-        else callback();
-      },
-      trigger: "change",
+      pattern: /^1\d{10}$/,
+      message: "请输入正确的手机号",
+      trigger: "blur",
     },
   ],
-  areaLabel: [
+  residentLocation: [
     {
       validator: (_rule, _value, callback) => {
-        if (!form.selectedAreaIds?.length) callback(new Error("请选择执行区域"));
+        if (!form.residentLocation) callback(new Error("请选择常驻点位"));
         else callback();
       },
       trigger: "change",
     },
   ],
-  ownerName: [{ required: true, message: "请输入负责人", trigger: "blur" }],
-  ownerPhone: [
-    { required: true, message: "请输入手机号", trigger: "blur" },
-    {
-      pattern: /^1\d{10}$/,
-      message: "请输入正确的手机号",
-      trigger: "blur",
-    },
-  ],
+};
+
+const activeLocationWkt = computed(() => {
+  const target = locationTarget.value;
+  if (!target || target.type === "resident") return form.locationWkt || "";
+  const planting =
+    form.categories[target.categoryIndex]?.plantings?.[target.plantingIndex];
+  return planting?.plantLocationWkt || "";
+});
+
+const normalizeCategoriesFromData = (data) => {
+  if (Array.isArray(data?.categories) && data.categories.length) {
+    return data.categories.map((c) => {
+      if (typeof c === "string") {
+        return createCategory({
+          category: c,
+          plantings: data.plantings || [],
+        });
+      }
+      return createCategory({
+        category: c.category || c.name || c.cropCategory || "",
+        plantings: c.plantings || [],
+      });
+    });
+  }
+
+  return [
+    createCategory({
+      category: data?.cropCategory || data?.type || "荔枝",
+      plantings: data?.plantings || [],
+    }),
+  ];
 };
 
 const fillForm = (data) => {
   Object.assign(form, createEmptyForm(), {
     id: data?.id ?? null,
-    name: data?.name || "",
-    type: data?.type || "",
-    categories: [...(data?.categories || [])],
-    location: data?.location || "",
+    ownerName: data?.ownerName || data?.name || "",
+    ownerPhone: data?.ownerPhone || data?.phone || "",
+    residentLocation: data?.residentLocation || data?.location || "",
     locationWkt: data?.locationWkt || "",
-    areaLabel: data?.areaLabel || "",
-    selectedAreaIds: [...(data?.selectedAreaIds || [])],
-    selectedAreas: [...(data?.selectedAreas || [])],
-    ownerName: data?.ownerName || "",
-    ownerPhone: data?.ownerPhone || "",
+    categories: normalizeCategoriesFromData(data),
   });
 };
 
@@ -247,51 +267,126 @@ const close = () => emit("update:modelValue", false);
 
 const handleClosed = () => {
   submitting.value = false;
-  areaDialogVisible.value = false;
   locationDialogVisible.value = false;
+  locationTarget.value = null;
   formRef.value?.resetFields?.();
   Object.assign(form, createEmptyForm());
 };
 
-const handleSelectLocation = () => {
+const addCategory = () => {
+  form.categories.push(createCategory({ category: "品类一" }));
+};
+
+const removeCategory = (gIdx) => {
+  if (form.categories.length <= 1) return;
+  form.categories.splice(gIdx, 1);
+};
+
+const addPlanting = (gIdx) => {
+  form.categories[gIdx]?.plantings.push(createPlanting());
+};
+
+const removePlanting = (gIdx, pIdx) => {
+  const group = form.categories[gIdx];
+  if (!group) return;
+  if (group.plantings.length <= 1) {
+    if (form.categories.length > 1) removeCategory(gIdx);
+    return;
+  }
+  group.plantings.splice(pIdx, 1);
+};
+
+const openResidentLocation = () => {
+  locationTarget.value = { type: "resident" };
   locationDialogVisible.value = true;
 };
 
-const handleLocationConfirm = (point) => {
-  form.locationWkt = point.wkt;
-  form.location = point.label;
-  formRef.value?.validateField?.("location");
+const openPlantLocation = (categoryIndex, plantingIndex) => {
+  locationTarget.value = { type: "plant", categoryIndex, plantingIndex };
+  locationDialogVisible.value = true;
 };
 
-const handleSelectArea = () => {
-  areaDialogVisible.value = true;
+const handleLocationConfirm = (point) => {
+  const target = locationTarget.value;
+  if (target?.type === "plant") {
+    const planting =
+      form.categories[target.categoryIndex]?.plantings?.[target.plantingIndex];
+    if (planting) {
+      planting.plantLocationWkt = point.wkt;
+      planting.plantLocation = point.label;
+    }
+  } else {
+    form.locationWkt = point.wkt;
+    form.residentLocation = point.label;
+    formRef.value?.validateField?.("residentLocation");
+  }
+  locationTarget.value = null;
 };
 
-const handleAreaConfirm = ({ areas, ids, label }) => {
-  form.selectedAreas = areas;
-  form.selectedAreaIds = ids;
-  form.areaLabel = label;
-  formRef.value?.validateField?.("areaLabel");
+const validatePlantings = () => {
+  for (const group of form.categories) {
+    if (!group.category) {
+      ElMessage.warning("请选择种植品类");
+      return false;
+    }
+    for (const item of group.plantings) {
+      if (!item.variety) {
+        ElMessage.warning("请选择种植品种");
+        return false;
+      }
+      if (!item.area) {
+        ElMessage.warning("请填写种植面积");
+        return false;
+      }
+      if (!item.plantLocation) {
+        ElMessage.warning("请选择种植点位");
+        return false;
+      }
+    }
+  }
+  return true;
 };
 
 const handleSubmit = async () => {
   await formRef.value?.validate?.(async (valid) => {
     if (!valid) return;
+    if (!validatePlantings()) return;
+
     submitting.value = true;
     try {
+      const categories = form.categories.map(({ category, plantings }) => ({
+        category,
+        plantings: plantings.map(
+          ({ variety, area, startPoint, startDate, plantLocation, plantLocationWkt, phenology }) => ({
+            variety,
+            area,
+            startPoint,
+            startDate,
+            plantLocation,
+            plantLocationWkt,
+            phenology: phenology || "生育期一",
+          })
+        ),
+      }));
+
+      const plantings = categories.flatMap((g) =>
+        g.plantings.map((p) => ({ ...p, cropCategory: g.category }))
+      );
+
       const payload = {
         id: form.id,
-        name: form.name,
-        type: form.type,
-        categories: [...form.categories],
-        location: form.location,
-        locationWkt: form.locationWkt,
-        areaLabel: form.areaLabel,
-        selectedAreaIds: [...form.selectedAreaIds],
-        selectedAreas: [...form.selectedAreas],
         ownerName: form.ownerName,
         ownerPhone: form.ownerPhone,
+        name: form.ownerName,
+        phone: form.ownerPhone,
+        residentLocation: form.residentLocation,
+        location: form.residentLocation,
+        locationWkt: form.locationWkt,
+        cropCategory: categories[0]?.category || "",
+        categories,
+        plantings,
       };
+
       emit("submit", { mode: props.mode, data: payload });
       ElMessage.success(isEdit.value ? "编辑成功" : "新增成功");
       close();
@@ -304,49 +399,209 @@ const handleSubmit = async () => {
 
 <style lang="scss" scoped>
 .device-form {
-  padding: 20px 24px 0 8px;
-  border-top: 1px solid #E5E6EB;
+  padding: 16px 18px 0;
+  border-top: 1px solid #e5e6eb;
+  max-height: 68vh;
+  overflow: auto;
 
   :deep(.el-form-item) {
-    margin-bottom: 24px;
+    margin-bottom: 18px;
+  }
+
+  :deep(.el-form-item__label) {
+    color: #1d2129;
+    font-size: 14px;
+    padding-right: 0;
   }
 }
 
-.field-block {
+.form-item--inline {
+  :deep(.el-form-item__content) {
+    flex: 1;
+  }
+}
+
+.name-phone-row {
   display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  gap: 10px;
+  gap: 12px;
   width: 100%;
+
+  &__name {
+    width: 190px;
+    flex-shrink: 0;
+  }
+
+  &__phone-item {
+    width: 190px;
+    margin-bottom: 0 !important;
+
+    :deep(.el-form-item__content) {
+      margin-left: 0 !important;
+    }
+  }
 }
 
-.btn-outline {
-  height: 32px;
-  padding: 0 16px;
-  color: $base-color;
+.form-section {
+  margin-bottom: 18px;
+}
+
+.form-section__head {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  padding-bottom: 10px;
+}
+
+.form-section__label {
+  color: #1D2129;
+  font-size: 16px;
+}
+
+.category-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.category-block + .category-block {
+  border-top: 1px solid #E5E6EB;
+  padding-top: 16px;
+}
+
+.category-block__head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 10px;
+}
+
+.category-select {
+  width: 120px;
+
+  :deep(.el-select__wrapper) {
+    // background: rgba($warning-color, 0.12);
+    box-shadow: 0 0 0 1px $warning-color inset;
+    border-radius: 2px;
+
+    .el-select__selected-item,
+    .el-select__placeholder,
+    .el-select__caret {
+      color: $warning-color;
+    }
+  }
+}
+
+.planting-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.planting-card {
+  box-sizing: border-box;
+  padding: 12px;
+  border: 1px solid #e5e6eb;
+  border-radius: 6px;
   background: #fff;
-  border-color: $base-color;
+}
+
+.planting-card__top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
+.variety-select {
+  width: 120px;
+}
+
+.planting-card__row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.planting-card__label {
+  flex-shrink: 0;
+  display: inline-block;
+  width: 70px;
+  color: #1d2129;
+  font-size: 14px;
+  line-height: 22px;
+}
+
+.planting-card__input {
+  flex: 1;
+}
+
+.start-point-select {
+  width: 110px;
+  flex-shrink: 0;
+}
+
+.start-date-picker {
+  flex: 1;
+  width: auto !important;
+}
+
+.location-box {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 0 10px;
+  height: 32px;
+  border: 1px solid #dcdfe6;
   border-radius: 4px;
+  background: #fff;
+  box-sizing: border-box;
 
-  &:hover,
-  &:focus {
-    color: #fff;
-    background: $base-color;
-    border-color: $base-color;
+  &--full {
+    width: 100%;
   }
 }
 
-.area-preview {
-  min-width: 148px;
-  max-width: 100%;
-  padding: 10px 12px;
-  border: 1px solid #e5e5e5;
-  border-radius: 2px;
-  background: #f7f7f7;
-  color: #666;
+.location-box__text {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: #1d2129;
   font-size: 13px;
-  line-height: 1.5;
-  word-break: break-all;
+}
+
+.link-btn {
+  padding: 0;
+  font-size: 14px;
+  flex-shrink: 0;
+}
+.secondary-link {
+  padding: 0 6px;
+  color: #FB8E33;
+  background: rgba(251, 142, 51, 0.1);
+  height: 25px;
+  line-height: 25px;
+  border-radius: 2px;
+}
+
+.add-link {
+  margin-top: 8px;
+}
+
+.form-item--resident {
+  :deep(.el-form-item__content) {
+    flex: 1;
+  }
 }
 
 .dialog-footer {

+ 1 - 1
src/views/device/SelectAreaDialog.vue

@@ -120,7 +120,7 @@ const handleClosed = () => {
   position: relative;
   width: 100%;
   height: 520px;
-  background: #1a1a1a;
+  background: #d3d7d8;
   border-radius: 4px;
   overflow: hidden;
   cursor: pointer;

+ 13 - 10
src/views/device/SelectLocationDialog.vue

@@ -85,17 +85,20 @@ const loadRegions = async () => {
     regionCount.value = list.length;
     locationMap?.setRegions(list);
 
+    // 等 fit 完成后再放点,避免先出现在默认位置再消失
     await nextTick();
-    if (props.locationWkt) {
-      try {
-        const coord = wktCastGeom(props.locationWkt).getFirstCoordinate();
-        locationMap?.setPoint(coord);
-      } catch (e) {
+    requestAnimationFrame(() => {
+      if (props.locationWkt) {
+        try {
+          const coord = wktCastGeom(props.locationWkt).getFirstCoordinate();
+          locationMap?.setPoint(coord);
+        } catch (e) {
+          locationMap?.setPointToCenter();
+        }
+      } else {
         locationMap?.setPointToCenter();
       }
-    } else {
-      locationMap?.setPointToCenter();
-    }
+    });
 
     if (!list.length && res?.msg) {
       ElMessage.warning(res.msg);
@@ -131,10 +134,10 @@ const handleClosed = () => {
   position: relative;
   width: 100%;
   height: 520px;
-  background: #1a1a1a;
+  background: #d3d7d8;
   border-radius: 4px;
   overflow: hidden;
-  cursor: crosshair;
+  cursor: pointer;
 }
 
 .map-el {

+ 238 - 176
src/views/device/index.vue

@@ -5,13 +5,13 @@
       <!-- 筛选 -->
       <div class="filter-bar">
         <div class="filter-l">
-          <span class="filter-bar__label">搜索设备名称</span>
+          <span class="filter-bar__label">搜索姓名</span>
           <el-select
             v-model="deviceName"
             class="filter-bar__select"
             clearable
             filterable
-            placeholder="请选择设备名称"
+            placeholder="请选择姓名"
           >
             <el-option
               v-for="item in deviceNameOptions"
@@ -38,82 +38,67 @@
           <el-button class="btn-add" type="warning" @click="handleAdd">新增设备</el-button>
         </div>
 
-        <el-table :data="pagedList" class="device-table" row-key="id" header-cell-class-name="device-table__header"
-          @selection-change="handleSelectionChange">
-          <el-table-column type="selection" width="48" align="center" />
-
-          <el-table-column prop="code" label="农机代码" min-width="140" show-overflow-tooltip>
+        <el-table
+          :data="tableData"
+          :span-method="objectSpanMethod"
+          class="device-table"
+          row-key="rowKey"
+          border
+          header-cell-class-name="device-table__header"
+        >
+          <el-table-column label="姓名/手机号" min-width="120">
             <template #default="{ row }">
-              <span class="code-tag">{{ row.code }}</span>
+              <div class="person-cell">
+                <div class="person-cell__name">{{ row.name }}</div>
+                <div class="person-cell__phone">{{ row.phone }}</div>
+              </div>
             </template>
           </el-table-column>
 
-          <el-table-column prop="name" label="农机名称" min-width="120" show-overflow-tooltip />
+          <el-table-column prop="cropCategory" label="种植品类" min-width="100" show-overflow-tooltip />
 
-          <el-table-column label="农机类型" min-width="110">
+          <el-table-column label="种植品种" min-width="110">
             <template #default="{ row }">
-              <span class="type-tag">{{ row.type }}</span>
+              <span class="variety-tag">{{ row.variety }}</span>
             </template>
           </el-table-column>
 
-          <el-table-column label="农事类别" min-width="160">
+          <el-table-column prop="phenology" label="物候期" min-width="110" />
+
+          <el-table-column prop="area" label="种植面积" min-width="100" />
+
+          <el-table-column label="起种点" min-width="170">
             <template #default="{ row }">
-              <div class="category-grid">
-                <span v-for="(item, idx) in row.categories" :key="idx" class="category-grid__item">
-                  {{ item }}
-                </span>
+              <div class="start-point-cell">
+                <span class="start-point-tag">{{ row.startPoint }}</span>
+                <span class="start-point-date">{{ row.startDate }}</span>
               </div>
             </template>
           </el-table-column>
 
-          <el-table-column label="存放点位" min-width="170">
+          <el-table-column label="种植点位" min-width="160">
             <template #default="{ row }">
               <div class="location-cell">
-                <div class="location-cell__row">
-                  <el-icon class="location-cell__icon">
-                    <Location />
-                  </el-icon>
-                  <span class="location-cell__text" :title="row.location">{{ row.location }}</span>
-                </div>
-                <el-button link type="warning" class="link-btn" @click="handleViewLocation(row)">
+                <span class="location-cell__text" :title="row.plantLocation">{{ row.plantLocation }}</span>
+                <el-button link type="warning" class="link-btn" @click="handleViewPlantLocation(row)">
                   查看点位
                 </el-button>
               </div>
             </template>
           </el-table-column>
 
-          <el-table-column label="执行区域" width="120" align="center">
-            <template #default="{ row }">
-              <div class="area-thumb">
-                一区,二区
-                <el-button link type="warning" class="link-btn" @click="handleViewArea(row)">查看区域</el-button>
-              </div>
-            </template>
-          </el-table-column>
-
-          <el-table-column label="负责人" min-width="150">
-            <template #default="{ row }">
-              <div class="owner-cell">
-                <div>{{ row.ownerName }}</div>
-                <div class="owner-cell__phone">{{ row.ownerPhone }}</div>
-              </div>
-            </template>
-          </el-table-column>
-
-          <!-- <el-table-column label="服务记录" min-width="260">
+          <el-table-column label="常驻点位" min-width="160">
             <template #default="{ row }">
-              <div class="service-cell">
-                <div v-for="(record, idx) in row.serviceRecords.slice(0, 3)" :key="idx" class="service-cell__line">
-                  {{ record.date }} | {{ record.name }} | 作业区域: {{ record.area }}
-                </div>
-                <el-button link type="warning" class="link-btn" @click="handleViewMore(row)">
-                  查看更多
+              <div class="location-cell">
+                <span class="location-cell__text" :title="row.residentLocation">{{ row.residentLocation }}</span>
+                <el-button link type="warning" class="link-btn" @click="handleViewResidentLocation(row)">
+                  查看点位
                 </el-button>
               </div>
             </template>
-          </el-table-column> -->
+          </el-table-column>
 
-          <el-table-column label="操作" width="150" fixed="right" align="center">
+          <el-table-column label="操作" width="168" fixed="right" align="center">
             <template #default="{ row }">
               <div class="ops">
                 <el-button class="ops__btn" @click="handleEdit(row)">编辑</el-button>
@@ -153,46 +138,37 @@
 <script setup>
 import { computed, ref } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
-import { Location } from "@element-plus/icons-vue";
 import DeviceFormDialog from "./DeviceFormDialog.vue";
 import DeviceDetailDialog from "./DeviceDetailDialog.vue";
 
-const tabs = ref([
-  { key: "all", label: "全部设备", count: 158 },
-  { key: "type-a", label: "某某类", count: 152 },
-  { key: "type-b", label: "某某类", count: 158 },
-  { key: "type-c", label: "某某类", count: 158 },
-]);
-
-const activeTab = ref("type-a");
-const page = ref(1);
-const pageSize = ref(20);
-const selectedRows = ref([]);
-const deviceName = ref("");
-const appliedDeviceName = ref("");
-
-const DEVICE_NAMES = ["水肥一体机", "植保无人机", "收割机", "播种机"];
+const NAMES = ["张洋", "李明", "王芳", "赵强", "陈丽"];
+const VARIETIES = [
+  ["妃子笑", "桂味"],
+  ["糯米糍", "白糖罂"],
+  ["仙进奉", "桂味", "妃子笑"],
+  ["桂味"],
+];
 
-const buildMockRows = (count = 101) => {
+const buildMockRows = (count = 40) => {
   const list = [];
   for (let i = 1; i <= count; i++) {
+    // 保证首条与设计图一致:一人 + 一品类 + 多品种
+    const varieties = i === 1 ? ["妃子笑", "桂味"] : VARIETIES[i % VARIETIES.length];
     list.push({
       id: i,
-      code: "农场-类型-ID",
-      name: DEVICE_NAMES[i % DEVICE_NAMES.length],
-      type: "水肥一体",
-      categories: ["类别一", "类别二", "类别三", "类别四", "类别五", "类别六"],
-      location: "位置位置位置位置",
-      areaLabel: "一区,二区",
-      ownerName: "张扬",
-      ownerPhone: "19871513658",
-      serviceRecords: [
-        { date: "2024-08-20", name: "农事名称", area: "3区" },
-        { date: "2024-08-20", name: "农事名称", area: "3区" },
-        { date: "2024-08-20", name: "农事名称", area: "3区" },
-        { date: "2024-08-19", name: "农事名称", area: "2区" },
-      ],
-      categoryKey: i % 3 === 0 ? "type-b" : i % 2 === 0 ? "type-c" : "type-a",
+      name: i === 1 ? "张洋" : NAMES[i % NAMES.length],
+      phone: i === 1 ? "19871533168" : `1987153${String(3160 + (i % 40)).padStart(4, "0")}`,
+      cropCategory: "荔枝",
+      residentLocation: "位置位置位置位置",
+      tabKey: i % 2 === 0 ? "service" : "owner",
+      plantings: varieties.map((variety) => ({
+        variety,
+        phenology: "生育期一",
+        area: "158亩",
+        startPoint: "来白点",
+        startDate: "2025.05.08",
+        plantLocation: "位置位置位置位置",
+      })),
     });
   }
   return list;
@@ -200,28 +176,88 @@ const buildMockRows = (count = 101) => {
 
 const allList = ref(buildMockRows());
 
+const tabs = computed(() => [
+  {
+    key: "owner",
+    label: "权属人员",
+    count: allList.value.filter((i) => i.tabKey === "owner").length,
+  },
+  {
+    key: "service",
+    label: "农服人员",
+    count: allList.value.filter((i) => i.tabKey === "service").length,
+  },
+]);
+
+const activeTab = ref("owner");
+const page = ref(1);
+const pageSize = ref(20);
+const deviceName = ref("");
+const appliedDeviceName = ref("");
+
 const deviceNameOptions = computed(() => {
   return [...new Set(allList.value.map((item) => item.name))];
 });
 
-const filteredList = computed(() => {
-  let list = allList.value;
-  if (activeTab.value !== "all") {
-    list = list.filter((item) => item.categoryKey === activeTab.value);
-  }
+/** 人员维度过滤 */
+const filteredPersons = computed(() => {
+  let list = allList.value.filter((item) => item.tabKey === activeTab.value);
   if (appliedDeviceName.value) {
     list = list.filter((item) => item.name === appliedDeviceName.value);
   }
   return list;
 });
 
-const total = computed(() => filteredList.value.length);
+const total = computed(() => filteredPersons.value.length);
 
-const pagedList = computed(() => {
+/** 人员维度分页后,按种植记录展开为表格行 */
+const tableData = computed(() => {
   const start = (page.value - 1) * pageSize.value;
-  return filteredList.value.slice(start, start + pageSize.value);
+  const persons = filteredPersons.value.slice(start, start + pageSize.value);
+  const rows = [];
+
+  persons.forEach((person) => {
+    const plantings = person.plantings?.length
+      ? person.plantings
+      : [{ variety: "-", phenology: "-", area: "-", startPoint: "-", startDate: "-", plantLocation: "-" }];
+
+    plantings.forEach((planting, idx) => {
+      rows.push({
+        ...person,
+        ...planting,
+        rowKey: `${person.id}-${idx}`,
+        plantIndex: idx,
+        plantCount: plantings.length,
+        // 兼容原弹窗字段
+        code: person.code || "农场-类型-ID",
+        type: person.cropCategory,
+        location: person.residentLocation,
+        ownerName: person.name,
+        ownerPhone: person.phone,
+        categories: [],
+        serviceRecords: [],
+      });
+    });
+  });
+
+  return rows;
 });
 
+/** 需合并的列:姓名/手机号、种植品类、常驻点位、操作 */
+const MERGE_COLUMN_INDEXES = new Set([0, 1, 7, 8]);
+
+const objectSpanMethod = ({ row, columnIndex }) => {
+  if (!MERGE_COLUMN_INDEXES.has(columnIndex)) {
+    return { rowspan: 1, colspan: 1 };
+  }
+
+  if (row.plantIndex === 0) {
+    return { rowspan: row.plantCount, colspan: 1 };
+  }
+
+  return { rowspan: 0, colspan: 0 };
+};
+
 const handleTabChange = (key) => {
   activeTab.value = key;
   page.value = 1;
@@ -238,10 +274,6 @@ const handleReset = () => {
   page.value = 1;
 };
 
-const handleSelectionChange = (rows) => {
-  selectedRows.value = rows;
-};
-
 const dialogVisible = ref(false);
 const dialogMode = ref("add");
 const editingRow = ref(null);
@@ -255,13 +287,28 @@ const handleAdd = () => {
 };
 
 const handleEdit = (row) => {
+  const person = allList.value.find((item) => item.id === row.id) || row;
   dialogMode.value = "edit";
-  editingRow.value = { ...row };
+  editingRow.value = {
+    ...person,
+    ownerName: person.name,
+    ownerPhone: person.phone,
+    location: person.residentLocation,
+  };
   dialogVisible.value = true;
 };
 
 const handleDetail = (row) => {
-  detailRow.value = { ...row };
+  const person = allList.value.find((item) => item.id === row.id) || row;
+  detailRow.value = {
+    ...person,
+    // 兼容弹窗字段
+    code: person.code || "农场-类型-ID",
+    type: person.cropCategory,
+    location: person.residentLocation,
+    ownerName: person.name,
+    ownerPhone: person.phone,
+  };
   detailVisible.value = true;
 };
 
@@ -272,14 +319,43 @@ const handleDetailEdit = () => {
 };
 
 const handleDialogSubmit = ({ mode, data }) => {
+  const plantings = (data.plantings || []).map((p) => ({
+    variety: p.variety,
+    phenology: p.phenology || "生育期一",
+    area: p.area,
+    startPoint: p.startPoint,
+    startDate: p.startDate,
+    plantLocation: p.plantLocation,
+    plantLocationWkt: p.plantLocationWkt || "",
+  }));
+
+  const categories = (data.categories || []).map((g) => ({
+    category: g.category,
+    plantings: (g.plantings || []).map((p) => ({
+      variety: p.variety,
+      phenology: p.phenology || "生育期一",
+      area: p.area,
+      startPoint: p.startPoint,
+      startDate: p.startDate,
+      plantLocation: p.plantLocation,
+      plantLocationWkt: p.plantLocationWkt || "",
+    })),
+  }));
+
   if (mode === "edit" && data.id != null) {
     const idx = allList.value.findIndex((item) => item.id === data.id);
     if (idx > -1) {
       allList.value[idx] = {
         ...allList.value[idx],
-        ...data,
+        name: data.ownerName || data.name || allList.value[idx].name,
+        phone: data.ownerPhone || data.phone || allList.value[idx].phone,
+        residentLocation:
+          data.residentLocation || data.location || allList.value[idx].residentLocation,
+        locationWkt: data.locationWkt || allList.value[idx].locationWkt || "",
+        cropCategory: data.cropCategory || categories[0]?.category || allList.value[idx].cropCategory,
+        categories,
+        plantings: plantings.length ? plantings : allList.value[idx].plantings,
       };
-      // 同步详情缓存
       if (detailRow.value?.id === data.id) {
         detailRow.value = { ...allList.value[idx] };
       }
@@ -289,19 +365,34 @@ const handleDialogSubmit = ({ mode, data }) => {
 
   const nextId = allList.value.reduce((max, item) => Math.max(max, item.id), 0) + 1;
   allList.value.unshift({
-    ...data,
     id: nextId,
-    code: "农场-类型-ID",
-    categoryKey: activeTab.value === "all" ? "type-a" : activeTab.value,
-    serviceRecords: [],
+    name: data.ownerName || data.name || "新人员",
+    phone: data.ownerPhone || data.phone || "",
+    cropCategory: data.cropCategory || categories[0]?.category || "荔枝",
+    residentLocation: data.residentLocation || data.location || "位置位置位置位置",
+    locationWkt: data.locationWkt || "",
+    tabKey: activeTab.value,
+    categories,
+    plantings: plantings.length
+      ? plantings
+      : [
+          {
+            variety: "妃子笑",
+            phenology: "生育期一",
+            area: "158亩",
+            startPoint: "来白点",
+            startDate: "2025.05.08",
+            plantLocation: "位置位置位置位置",
+          },
+        ],
   });
 };
 
-const handleViewLocation = () => ElMessage.info("查看存放点位");
-const handleViewArea = () => ElMessage.info("查看执行区域");
+const handleViewPlantLocation = () => ElMessage.info("查看种植点位");
+const handleViewResidentLocation = () => ElMessage.info("查看常驻点位");
 
 const handleDelete = (row) => {
-  ElMessageBox.confirm(`确认删除设备「${row.name}」吗?`, "提示", {
+  ElMessageBox.confirm(`确认删除「${row.name}」吗?`, "提示", {
     type: "warning",
     confirmButtonText: "删除",
     cancelButtonText: "取消",
@@ -310,7 +401,7 @@ const handleDelete = (row) => {
       allList.value = allList.value.filter((item) => item.id !== row.id);
       ElMessage.success("已删除");
     })
-    .catch(() => { });
+    .catch(() => {});
 };
 </script>
 
@@ -466,6 +557,10 @@ const handleDelete = (row) => {
     vertical-align: middle;
   }
 
+  :deep(.el-table__body .cell) {
+    padding: 0 12px;
+  }
+
   :deep(.el-checkbox__input.is-checked .el-checkbox__inner),
   :deep(.el-checkbox__input.is-indeterminate .el-checkbox__inner) {
     background-color: $warning-color;
@@ -473,7 +568,22 @@ const handleDelete = (row) => {
   }
 }
 
-.type-tag {
+.person-cell {
+  color: #535353;
+  font-size: 14px;
+  line-height: 22px;
+}
+
+.person-cell__name {
+  font-weight: 500;
+}
+
+.person-cell__phone {
+  color: #818080;
+  font-size: 13px;
+}
+
+.variety-tag {
   display: inline-block;
   padding: 2px 10px;
   border-radius: 4px;
@@ -483,23 +593,26 @@ const handleDelete = (row) => {
   line-height: 22px;
 }
 
-.category-grid {
+.start-point-cell {
   display: flex;
+  align-items: center;
+  gap: 8px;
   flex-wrap: wrap;
-  gap: 4px;
+}
+
+.start-point-tag {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 4px;
+  background: #f2f2f2;
   color: #666;
-  font-size: 14px;
+  font-size: 12px;
   line-height: 20px;
-  .category-grid__item {
-    width: fit-content;
-    background: #F2F2F2;
-    border-radius: 2px;
-    padding: 1px 6px;
-  }
 }
 
-.code-tag {
-  color: #818080;
+.start-point-date {
+  color: #535353;
+  font-size: 14px;
 }
 
 .location-cell {
@@ -509,22 +622,11 @@ const handleDelete = (row) => {
   gap: 2px;
 }
 
-.location-cell__row {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  max-width: 100%;
-}
-
-.location-cell__icon {
-  color: #999;
-  flex-shrink: 0;
-}
-
 .location-cell__text {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  max-width: 100%;
   color: #535353;
   font-size: 14px;
 }
@@ -535,44 +637,6 @@ const handleDelete = (row) => {
   font-size: 14px;
 }
 
-.area-thumb {
-  width: 88px;
-  height: 56px;
-  margin: 0 auto;
-  border-radius: 2px;
-  overflow: hidden;
-  cursor: pointer;
-}
-
-.owner-cell {
-  color: #535353;
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  color: #666;
-  font-size: 14px;
-  line-height: 20px;
-}
-
-.owner-cell__phone {
-  // color: #999;
-  font-weight: 500;
-}
-
-.service-cell {
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  gap: 2px;
-}
-
-.service-cell__line {
-  color: #666;
-  font-size: 12px;
-  line-height: 18px;
-  white-space: nowrap;
-}
-
 .ops {
   display: flex;
   align-items: center;
@@ -585,9 +649,9 @@ const handleDelete = (row) => {
   height: 28px;
   padding: 0 6px;
   font-size: 14px;
-  color: #4C4C4C;
-  background: #F2F2F2;
-  border: 1px solid #D2D2D2;
+  color: #4c4c4c;
+  background: #f2f2f2;
+  border: 1px solid #d2d2d2;
   border-radius: 2px;
 
   &:hover {
@@ -599,13 +663,11 @@ const handleDelete = (row) => {
 
 .ops__btn--danger {
   color: #fff !important;
-  background: #F56B6B !important;
-  border-color: #F56B6B !important;
+  background: #f56b6b !important;
+  border-color: #f56b6b !important;
 
   &:hover {
     color: #fff !important;
-    // background: mix(#fff, #F56B6B, 12%) !important;
-    // border-color: mix(#fff, #F56B6B, 12%) !important;
   }
 }
 

+ 3 - 2
src/views/device/locationSelectMap.js

@@ -79,7 +79,7 @@ export default class LocationSelectMap {
     const xyz = config.base_img_url + "map/lby/{z}/{x}/{y}.png";
     this.kmap.addXYZLayer(xyz, { minZoom: 8, maxZoom: 22 }, 2);
 
-    this.setPoint(coordinate);
+    // 点位等区域加载并 fit 完成后再放置,避免闪一下
     this.addMapSingleClick();
     setTimeout(() => this.updateSize(), 80);
   }
@@ -133,7 +133,8 @@ export default class LocationSelectMap {
   fitToRegions() {
     const extent = this.blueRegionLayer?.source?.getExtent?.();
     if (!extent || !isFinite(extent[0]) || !this.kmap) return;
-    this.kmap.fit(extent, { padding: [40, 40, 40, 40], duration: 300, maxZoom: 18 });
+    // 无动画,避免 fit 过程中点位位置错乱
+    this.kmap.fit(extent, { padding: [40, 40, 40, 40], duration: 0, maxZoom: 18 });
   }
 
   /** 将点位放到当前地图中心 */