zhangwencui
2 天以前 83946cf045cc431dcbf66b9e192eacd329da1148
打卡规则配置
已添加3个文件
已修改2个文件
806 ■■■■■ 文件已修改
index.html 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/personnelManagement/attendanceRules.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/attendanceCheckin/checkinRules/components/form.vue 461 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/attendanceCheckin/checkinRules/index.vue 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/attendanceCheckin/index.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
index.html
@@ -10,6 +10,10 @@
    />
    <link rel="icon" href="/favicon.ico" />
    <title>%VITE_APP_TITLE%</title>
    <!-- é«˜å¾·åœ°å›¾API -->
    <script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=6af5d2639adbbabf95eddfbf2bae5739"></script>
    <!-- é«˜å¾·åœ°å›¾æœç´¢æ’ä»¶ -->
    <script type="text/javascript" src="https://webapi.amap.com/loca?v=2.0.0&key=6af5d2639adbbabf95eddfbf2bae5739"></script>
    <!--[if lt IE 11
      ]><script>
        window.location.href = "/html/ie.html";
src/api/personnelManagement/attendanceRules.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import request from "@/utils/request";
// èŽ·å–æ‰“å¡è§„åˆ™åˆ—è¡¨
export function getAttendanceRules(query) {
  return request({
    url: "/personalAttendanceLocationConfig/listPage",
    method: "get",
    params: query,
  });
}
// æ–°å¢žæ‰“卡规则
export function addAttendanceRule(data) {
  return request({
    url: "/personalAttendanceLocationConfig/add",
    method: "post",
    data,
  });
}
// æ›´æ–°æ‰“卡规则
export function updateAttendanceRule(data) {
  return request({
    url: "/attendanceRules/update",
    method: "put",
    data,
  });
}
// åˆ é™¤æ‰“卡规则
export function deleteAttendanceRule(ids) {
  return request({
    url: `/personalAttendanceLocationConfig/del`,
    method: "delete",
    data: ids,
  });
}
// èŽ·å–å•ä¸ªæ‰“å¡è§„åˆ™è¯¦æƒ…
export function getAttendanceRuleDetail(id) {
  return request({
    url: `/attendanceRules/detail/${id}`,
    method: "get",
  });
}
src/views/personnelManagement/attendanceCheckin/checkinRules/components/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,461 @@
<template>
  <el-dialog v-model="dialogVisible"
             :title="dialogTitle"
             width="700px"
             :close-on-click-modal="false">
    <el-form ref="formRef"
             :model="form"
             :rules="rules"
             label-width="120px"
             class="mt8">
      <!-- éƒ¨é—¨é€‰æ‹© -->
      <el-form-item label="部门"
                    prop="sysDeptId">
        <el-tree-select v-model="form.sysDeptId"
                        :data="deptOptions"
                        :props="{ value: 'id', label: 'label', children: 'children' }"
                        value-key="id"
                        placeholder="请选择部门"
                        check-strictly
                        style="width: 100%"
                        :disabled="operationType === 'view'" />
      </el-form-item>
      <!-- åœ°ç‚¹ä¿¡æ¯ -->
      <!-- <el-form-item label="地点名称"
                    prop="locationName">
        <el-input v-model="form.locationName"
                  placeholder="请输入地点名称"
                  :disabled="operationType === 'view'" />
      </el-form-item> -->
      <!-- æ‰“卡范围 -->
      <el-form-item label="打卡范围(m)"
                    prop="radius">
        <el-input-number v-model="form.radius"
                         :min="10"
                         :max="1000"
                         :step="10"
                         placeholder="请输入打卡范围"
                         :disabled="operationType === 'view'" />
      </el-form-item>
      <!-- é«˜å¾·åœ°å›¾é€‰æ‹© -->
      <el-form-item label="打卡位置"
                    prop="longitude">
        <div class="map-container">
          <div class="map-header"
               style="margin-bottom: 10px">
            <el-button @click="getCurrentLocation">
              <el-icon>
                <Position />
              </el-icon>
              å½“前位置
            </el-button>
            <span style="margin-left: 10px; color: #909399;font-size: 12px;">点击地图选择位置</span>
          </div>
          <div id="map-container"
               class="map"
               ref="mapContainer"></div>
          <div class="coordinates-info mt10">
            <el-input v-model="form.longitude"
                      readonly
                      placeholder="经度"
                      style="width: 140px; margin-right: 10px" />
            <el-input v-model="form.latitude"
                      readonly
                      placeholder="纬度"
                      style="width: 140px; margin-right: 10px" />
            <!-- <el-input v-model="form.locationName"
                      placeholder="地点名称"
                      style="width: calc(100% - 290px)" /> -->
          </div>
        </div>
      </el-form-item>
      <el-form-item label="地点名称"
                    prop="locationName">
        <el-input v-model="form.locationName"
                  :disabled="operationType === 'view'"
                  placeholder="请输入地点名称" />
      </el-form-item>
      <!-- ä¸Šä¸‹ç­æ—¶é—´ -->
      <el-form-item label="上班时间"
                    prop="startAt">
        <el-time-picker v-model="form.startAt"
                        format="HH:mm"
                        value-format="HH:mm"
                        placeholder="请选择上班时间"
                        :disabled="operationType === 'view'" />
      </el-form-item>
      <el-form-item label="下班时间"
                    prop="endAt">
        <el-time-picker v-model="form.endAt"
                        format="HH:mm"
                        value-format="HH:mm"
                        placeholder="请选择下班时间"
                        :disabled="operationType === 'view'" />
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary"
                   @click="submitForm"
                   v-if="operationType !== 'view'">
          ç¡®å®š
        </el-button>
      </span>
    </template>
  </el-dialog>
</template>
<script setup>
  import { ref, reactive, computed, watch, onMounted, nextTick } from "vue";
  import { ElMessage } from "element-plus";
  import { Position } from "@element-plus/icons-vue";
  import { deptTreeSelect } from "@/api/system/user.js";
  import { addAttendanceRule } from "@/api/personnelManagement/attendanceRules.js";
  const props = defineProps({
    modelValue: {
      type: Boolean,
      default: false,
    },
    operationType: {
      type: String,
      default: "add",
    },
    row: {
      type: Object,
      default: () => ({}),
    },
  });
  const emit = defineEmits(["update:modelValue", "close"]);
  const dialogVisible = computed({
    get: () => props.modelValue,
    set: val => emit("update:modelValue", val),
  });
  const dialogTitle = computed(() => {
    if (props.operationType === "add") return "新增打卡规则";
    if (props.operationType === "edit") return "编辑打卡规则";
    return "查看打卡规则";
  });
  // è¡¨å•数据
  const formRef = ref();
  const form = reactive({
    id: "",
    sysDeptId: "",
    locationName: "",
    longitude: "",
    latitude: "",
    radius: 100,
    startAt: "09:00",
    endAt: "18:00",
  });
  // è¡¨å•验证规则
  const rules = {
    sysDeptId: [{ required: true, message: "请选择部门", trigger: "change" }],
    locationName: [
      { required: true, message: "请输入地点名称", trigger: "blur" },
    ],
    longitude: [{ required: true, message: "请选择打卡位置", trigger: "blur" }],
    latitude: [{ required: true, message: "请选择打卡位置", trigger: "blur" }],
    radius: [{ required: true, message: "请输入打卡范围", trigger: "blur" }],
    startAt: [{ required: true, message: "请选择上班时间", trigger: "change" }],
    endAt: [{ required: true, message: "请选择下班时间", trigger: "change" }],
  };
  // éƒ¨é—¨é€‰é¡¹
  const deptOptions = ref([]);
  // åœ°å›¾ç›¸å…³
  const mapContainer = ref(null);
  let map = null;
  let marker = null;
  let circle = null;
  // èŽ·å–éƒ¨é—¨åˆ—è¡¨
  const fetchDeptOptions = () => {
    deptTreeSelect().then(response => {
      deptOptions.value = filterDisabledDept(
        JSON.parse(JSON.stringify(response.data))
      );
    });
  };
  // è¿‡æ»¤ç¦ç”¨çš„部门
  const filterDisabledDept = deptList => {
    return deptList.filter(dept => {
      if (dept.disabled) {
        return false;
      }
      if (dept.children && dept.children.length) {
        dept.children = filterDisabledDept(dept.children);
      }
      return true;
    });
  };
  // åˆå§‹åŒ–地图
  const initMap = () => {
    nextTick(() => {
      if (window.AMap && mapContainer.value) {
        // åˆå§‹åŒ–地图
        map = new window.AMap.Map(mapContainer.value, {
          zoom: 16,
          center: [116.397428, 39.90923], // é»˜è®¤åŒ—京
        });
        // æ·»åŠ æŽ§ä»¶
        window.AMap.plugin(["AMap.ToolBar", "AMap.Scale"], function () {
          map.addControl(new window.AMap.ToolBar());
          map.addControl(new window.AMap.Scale());
        });
        // æ·»åŠ æ ‡è®°
        marker = new window.AMap.Marker({
          position: [116.397428, 39.90923],
          draggable: true,
          cursor: "move",
          title: "拖拽定位",
        });
        map.add(marker);
        // æ·»åŠ åœ†å½¢èŒƒå›´
        circle = new window.AMap.Circle({
          center: [116.397428, 39.90923],
          radius: form.radius,
          strokeColor: "#3366FF",
          strokeOpacity: 0.8,
          strokeWeight: 2,
          fillColor: "#3366FF",
          fillOpacity: 0.2,
        });
        map.add(circle);
        // ç›‘听标记拖拽
        marker.on("dragend", e => {
          const position = e.lnglat;
          const lng = position.getLng();
          const lat = position.getLat();
          form.longitude = lng;
          form.latitude = lat;
          updateCircle(position);
        });
        // ç›‘听标记拖拽开始
        marker.on("dragstart", () => {
          map.setDefaultCursor("move");
        });
        // ç›‘听标记拖拽结束
        marker.on("dragend", () => {
          map.setDefaultCursor("default");
        });
        // ç›‘听地图点击
        map.on("click", e => {
          const position = e.lnglat;
          const lng = position.getLng();
          const lat = position.getLat();
          form.longitude = lng;
          form.latitude = lat;
          updateMarker(position);
          updateCircle(position);
        });
        // å°è¯•获取当前位置并设置为地图中心
        if (navigator.geolocation && !form.longitude && !form.latitude) {
          navigator.geolocation.getCurrentPosition(
            position => {
              console.log("获取到当前位置:", position);
              const { longitude, latitude } = position.coords;
              const currentPosition = [longitude, latitude];
              map.setCenter(currentPosition);
              updateMarker(currentPosition);
              updateCircle(currentPosition);
              form.longitude = longitude;
              form.latitude = latitude;
            },
            error => {
              console.log("获取位置失败,使用默认位置");
            }
          );
        } else if (form.longitude && form.latitude) {
          // å¦‚果有数据,设置到地图
          const position = [form.longitude, form.latitude];
          map.setCenter(position);
          updateMarker(position);
          updateCircle(position);
        }
      }
    });
  };
  // æ›´æ–°æ ‡è®°ä½ç½®
  const updateMarker = position => {
    if (marker) {
      marker.setPosition(position);
    }
  };
  // æ›´æ–°åœ†å½¢èŒƒå›´
  const updateCircle = position => {
    if (circle) {
      circle.setCenter(position);
      circle.setRadius(form.radius);
    }
  };
  // èŽ·å–å½“å‰ä½ç½®
  const getCurrentLocation = () => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        position => {
          const { longitude, latitude } = position.coords;
          form.longitude = longitude;
          form.latitude = latitude;
          if (map) {
            map.setCenter([longitude, latitude]);
            updateMarker([longitude, latitude]);
            updateCircle([longitude, latitude]);
          }
          // é€†åœ°ç†ç¼–码获取地址
          if (window.AMap) {
            // åŠ è½½Geocoder插件
            window.AMap.plugin("AMap.Geocoder", function () {
              const geocoder = new window.AMap.Geocoder();
              geocoder.getAddress([longitude, latitude], (status, result) => {
                if (status === "complete" && result.regeocode) {
                  form.locationName = result.regeocode.formattedAddress;
                }
              });
            });
          }
        },
        error => {
          ElMessage.error("获取位置失败,请手动选择");
        }
      );
    } else {
      ElMessage.error("浏览器不支持地理定位");
    }
  };
  // ç›‘听半径变化
  watch(
    () => form.radius,
    newValue => {
      if (circle) {
        circle.setRadius(newValue);
      }
    }
  );
  // ç›‘听弹窗显示
  watch(
    () => dialogVisible.value,
    newValue => {
      if (newValue) {
        // é‡ç½®è¡¨å•
        Object.assign(form, {
          id: "",
          sysDeptId: "",
          locationName: "",
          longitude: "",
          latitude: "",
          radius: 100,
          startAt: "09:00",
          endAt: "18:00",
        });
        // å¦‚果是编辑或查看,填充数据
        if (props.operationType !== "add" && props.row.id) {
          // å¤„理时间格式,确保是HH:mm格式
          const rowData = { ...props.row };
          if (rowData.startAt && rowData.startAt.includes(":")) {
            rowData.startAt = rowData.startAt.split(":").slice(0, 2).join(":");
          }
          if (rowData.endAt && rowData.endAt.includes(":")) {
            rowData.endAt = rowData.endAt.split(":").slice(0, 2).join(":");
          }
          Object.assign(form, rowData);
        }
        // åˆå§‹åŒ–地图
        setTimeout(() => {
          initMap();
        }, 100);
      }
    }
  );
  // æäº¤è¡¨å•
  const submitForm = () => {
    formRef.value.validate(valid => {
      if (valid) {
        const submitData = {
          ...form,
          // è½¬æ¢æ—¶é—´æ ¼å¼ï¼Œç¡®ä¿åªä¿ç•™æ—¶åˆ†éƒ¨åˆ†
          startAt: form.startAt
            ? `${form.startAt.split(":").slice(0, 2).join(":")}`
            : null,
          endAt: form.endAt
            ? `${form.endAt.split(":").slice(0, 2).join(":")}`
            : null,
        };
        if (props.operationType === "add") {
          addAttendanceRule(submitData).then(() => {
            ElMessage.success("新增成功");
            emit("close");
          });
        } else if (props.operationType === "edit") {
          addAttendanceRule(submitData).then(() => {
            ElMessage.success("更新成功");
            emit("close");
          });
        }
      }
    });
  };
  // åˆå§‹åŒ–
  onMounted(() => {
    fetchDeptOptions();
  });
</script>
<style scoped lang="scss">
  .map-container {
    width: 100%;
  }
  .map {
    width: 100%;
    height: 400px;
    border: 1px solid #e4e7ed;
  }
  .coordinates-info {
    display: flex;
    gap: 10px;
  }
  .coordinates-display {
    padding: 10px;
    background-color: #f5f7fa;
    border-radius: 4px;
  }
  .mt10 {
    margin-top: 10px;
  }
  .mt8 {
    margin-top: 8px;
  }
</style>
src/views/personnelManagement/attendanceCheckin/checkinRules/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,293 @@
<template>
  <div class="app-container">
    <!-- é¡µé¢æ ‡é¢˜å’Œæ“ä½œæŒ‰é’® -->
    <div class="page-header">
      <div class="title">打卡规则配置</div>
      <div class="actions">
        <el-button type="primary"
                   @click="openForm('add')">
          <el-icon>
            <Plus />
          </el-icon>
          æ–°å¢žè§„则
        </el-button>
      </div>
    </div>
    <!-- æŸ¥è¯¢æ¡ä»¶ -->
    <!-- <el-form :model="searchForm"
             :inline="true"
             class="search-form mb16">
      <el-form-item label="部门:"
                    prop="countId">
        <el-tree-select v-model="searchForm.countId"
                        :data="deptOptions"
                        :props="{ value: 'id', label: 'label', children: 'children' }"
                        value-key="id"
                        placeholder="请选择部门"
                        check-strictly
                        style="width: 200px" />
      </el-form-item>
      <el-form-item label="地点:"
                    prop="locationName">
        <el-input v-model="searchForm.locationName"
                  placeholder="请输入地点名称"
                  clearable
                  style="width: 200px" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   @click="fetchData">
          <el-icon>
            <Search />
          </el-icon>
          æœç´¢
        </el-button>
        <el-button @click="resetSearch">
          <el-icon>
            <Refresh />
          </el-icon>
          é‡ç½®
        </el-button>
      </el-form-item>
    </el-form> -->
    <!-- è§„则列表 -->
    <el-card shadow="never"
             class="mb16">
      <el-table :data="tableData"
                border
                v-loading="tableLoading"
                style="width: 100%"
                row-key="id">
        <el-table-column type="index"
                         label="序号"
                         width="60"
                         align="center" />
        <el-table-column label="部门">
          <template #default="scope">
            {{ getDeptNameById(scope.row.sysDeptId) }}
          </template>
        </el-table-column>
        <el-table-column prop="locationName"
                         label="地点名称" />
        <el-table-column prop="longitude"
                         label="经度" />
        <el-table-column prop="latitude"
                         label="纬度" />
        <el-table-column prop="radius"
                         label="打卡范围(m)" />
        <el-table-column prop="startAt"
                         label="上班时间">
          <template #default="scope">
            {{ scope.row.startAt }}
          </template>
        </el-table-column>
        <el-table-column prop="endAt"
                         label="下班时间">
          <template #default="scope">
            {{ scope.row.endAt }}
          </template>
        </el-table-column>
        <el-table-column label="操作"
                         width="180"
                         fixed="right"
                         align="center">
          <template #default="scope">
            <el-button type="primary"
                       size="small"
                       link
                       @click="openForm('edit', scope.row)">编辑</el-button>
            <el-button type="danger"
                       size="small"
                       link
                       @click="handleDelete(scope.row.id)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
      <pagination :total="page.total"
                  layout="total, sizes, prev, pager, next, jumper"
                  :page="page.current"
                  :limit="page.size"
                  @pagination="paginationChange"
                  class="mt10" />
    </el-card>
    <!-- æ–°å¢ž/编辑规则弹窗 -->
    <rule-form ref="ruleFormRef"
               v-model="dialogVisible"
               :operation-type="operationType"
               :row="currentRow"
               @close="dialogVisible = false; fetchData()" />
  </div>
</template>
<script setup>
  import { ref, reactive, onMounted } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { Plus, Edit, Delete, Search, Refresh } from "@element-plus/icons-vue";
  import Pagination from "@/components/Pagination/index.vue";
  import RuleForm from "./components/form.vue";
  import { deptTreeSelect } from "@/api/system/user.js";
  import {
    getAttendanceRules,
    deleteAttendanceRule,
  } from "@/api/personnelManagement/attendanceRules.js";
  const { proxy } = getCurrentInstance();
  // è¡¨æ ¼æ•°æ®
  const tableData = ref([]);
  const tableLoading = ref(false);
  // åˆ†é¡µå‚æ•°
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  // æŸ¥è¯¢è¡¨å•
  const searchForm = reactive({
    countId: "",
    locationName: "",
  });
  // éƒ¨é—¨é€‰é¡¹
  const deptOptions = ref([]);
  // å¼¹çª—控制
  const dialogVisible = ref(false);
  const operationType = ref("add");
  const currentRow = ref({});
  const ruleFormRef = ref();
  // æ ¼å¼åŒ–æ—¶é—´
  const formatTime = timestamp => {
    if (!timestamp) return "";
    const date = new Date(timestamp);
    return `${String(date.getHours()).padStart(2, "0")}:${String(
      date.getMinutes()
    ).padStart(2, "0")}`;
  };
  // èŽ·å–éƒ¨é—¨åˆ—è¡¨
  const fetchDeptOptions = () => {
    deptTreeSelect().then(response => {
      deptOptions.value = filterDisabledDept(
        JSON.parse(JSON.stringify(response.data))
      );
    });
  };
  // è¿‡æ»¤ç¦ç”¨çš„部门
  const filterDisabledDept = deptList => {
    return deptList.filter(dept => {
      if (dept.disabled) {
        return false;
      }
      if (dept.children && dept.children.length) {
        dept.children = filterDisabledDept(dept.children);
      }
      return true;
    });
  };
  // æ ¹æ®éƒ¨é—¨ID查找部门名称
  const getDeptNameById = (deptId, deptList = deptOptions.value) => {
    for (const dept of deptList) {
      if (dept.id === deptId) {
        return dept.label;
      }
      if (dept.children && dept.children.length) {
        const name = getDeptNameById(deptId, dept.children);
        if (name) {
          return name;
        }
      }
    }
    return "";
  };
  // æŸ¥è¯¢è§„则列表
  const fetchData = () => {
    tableLoading.value = true;
    getAttendanceRules({ ...page, ...searchForm })
      .then(res => {
        tableData.value = res.data.records;
        page.total = res.data.total;
      })
      .finally(() => {
        tableLoading.value = false;
      });
  };
  // åˆ†é¡µå˜æ›´
  const paginationChange = pagination => {
    page.current = pagination.page;
    page.size = pagination.limit;
    fetchData();
  };
  // é‡ç½®æœç´¢
  const resetSearch = () => {
    searchForm.countId = "";
    searchForm.locationName = "";
    fetchData();
  };
  // æ‰“开表单
  const openForm = (type, row = {}) => {
    operationType.value = type;
    currentRow.value = row;
    dialogVisible.value = true;
  };
  // åˆ é™¤è§„则
  const handleDelete = id => {
    ElMessageBox.confirm("确定要删除这条规则吗?", "删除确认", {
      confirmButtonText: "确定",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        deleteAttendanceRule([id]).then(() => {
          ElMessage.success("删除成功");
          fetchData();
        });
      })
      .catch(() => {
        // å–消删除
      });
  };
  // åˆå§‹åŒ–
  onMounted(() => {
    fetchDeptOptions();
    fetchData();
  });
</script>
<style scoped lang="scss">
  .page-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
    .title {
      font-size: 18px;
      font-weight: 600;
    }
    .actions {
      display: flex;
      gap: 10px;
    }
  }
  .mb16 {
    margin-bottom: 16px;
  }
  .mt10 {
    margin-top: 10px;
  }
</style>
src/views/personnelManagement/attendanceCheckin/index.vue
@@ -5,7 +5,8 @@
             class="mb16">
      <div class="attendance-header">
        <div>
          <div class="title">打卡签到</div>
          <div class="title">打卡签到
          </div>
          <div class="sub-title">支持一键打卡,自动记录上下班时间</div>
        </div>
        <div class="attendance-actions">