瀏覽代碼

feat(attendance): 考勤管理模块与打卡规则弹窗设备管理功能

- 新增考勤管理菜单与路由:打卡规则设定、考勤统计
- 实现打卡规则弹窗:8 个 Tab(打卡方式、上下班时间、节假日、加班、补卡、请假、助理、外出)
- 打卡方式 Tab 集成位置、Wi-Fi、考勤设备子表
- 新增考勤机管理:新增/编辑弹窗、设备列表(在线状态、在线时长、绑定部门、最后同步、操作)
  支持查看详情/编辑/连接测试/同步数据/重启设备/删除/日志
- 节假日 Tab 内嵌工厂日历功能:内置 LUNAR_HOLIDAY_DATES / SOLAR_FIXED_HOLIDAYS 查找表
- factoryCalendar.js 接口桩(addCalendar/editCalendar/updateCalendarStatus)
- 调整 .gitignore:忽略 .history 与 tmp 目录
xieyong 3 天之前
父節點
當前提交
71183d2b78

+ 2 - 1
.gitignore

@@ -9,7 +9,8 @@ npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
 pnpm-debug.log*
-
+.history
+tmp
 .idea
 .vscode
 *.suo

+ 19 - 3
src/App.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <div id="oa-pc-app">
     <router-view v-if="isQiankun || isFullPage" />
     <ele-pro-layout v-else :menus="menus" :project-name="PROJECT_NAME" :collapse.sync="sidebarCollapsed" :show-tabs="false" :show-footer="false" home-path="/" layout-path="/">
@@ -45,6 +45,14 @@ export default {
           }
         ]
       },
+      {
+        path: '/contract',
+        meta: { title: '合同管理', icon: 'el-icon-document' },
+        children: [
+          { path: '/contract-template', meta: { title: '合同模版' } },
+          { path: '/contract-management', meta: { title: '合同管理' } }
+        ]
+      },
       {
         path: '/position',
         meta: { title: '岗位管理', icon: 'el-icon-suitcase' },
@@ -60,10 +68,18 @@ export default {
       { path: '/performance-scoring', meta: { title: '绩效评分', icon: 'el-icon-edit-outline' } },
       { path: '/performance-scheme', meta: { title: '绩效方案', icon: 'el-icon-document-checked' } },
       { path: '/personnel-structure-dashboard', meta: { title: '人员结构看板', icon: 'el-icon-data-analysis' } },
-      { path: '/personnel-structure-analysis-board', meta: { title: '人员结构分析看板', icon: 'el-icon-pie-chart' } }
+      { path: '/personnel-structure-analysis-board', meta: { title: '人员结构分析看板', icon: 'el-icon-pie-chart' } },
+      {
+        path: '/attendance',
+        meta: { title: '考勤管理', icon: 'el-icon-time' },
+        children: [
+          { path: '/attendance/rule', meta: { title: '打卡规则设定' } },
+          { path: '/attendance/report', meta: { title: '考勤统计' } }
+        ]
+      }
     ] };
   },
   computed: { isQiankun() { return Boolean(window.__POWERED_BY_QIANKUN__); }, isFullPage() { return Boolean(this.$route.meta?.fullPage); } }
 };
 </script>
-<style src="./styles/App.css"></style>
+<style src="./styles/App.css"></style>

+ 34 - 0
src/api/productionScheduling/factoryCalendar.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request';
+
+/**
+ * 工厂日历相关接口(占位实现)
+ *
+ * 当前后端接口尚未就绪,这里按既有约定(与 organization/index.js 一致:
+ * 响应 { code, data, message })实现调用契约。当后端接口完成时,
+ * 将请求路径替换为真实地址即可。节假日数据由组件内置查找表提供,无需远程接口。
+ */
+
+function unwrap(response) {
+  const result = response?.data;
+  if (result && result.code == 0) {
+    return result.data;
+  }
+  return Promise.reject(new Error(result?.message || '服务调用失败'));
+}
+
+// 新增工厂日历
+export async function addCalendar(data) {
+  return unwrap(await request.post('/productionScheduling/factoryCalendar/save', data));
+}
+
+// 编辑工厂日历
+export async function editCalendar(data) {
+  return unwrap(await request.put('/productionScheduling/factoryCalendar/update', data));
+}
+
+// 更新工厂日历启用/禁用状态
+export async function updateCalendarStatus(data) {
+  return unwrap(
+    await request.post('/productionScheduling/factoryCalendar/updateStatus', data)
+  );
+}

+ 7 - 3
src/router/index.js

@@ -1,4 +1,4 @@
-import Vue from 'vue';
+import Vue from 'vue';
 import VueRouter from 'vue-router';
 import HomeView from '@/views/HomeView.vue';
 import { getToken } from '@/utils/token-util';
@@ -33,7 +33,11 @@ const routes = [
   { path: '/resignation-application', name: 'resignationApplication', component: () => import('@/views/resignationApplication/index.vue'), meta: { title: '离职申请', public: dev } },
   { path: '/resignation-handover-order', name: 'resignationHandoverOrder', component: () => import('@/views/resignationHandoverOrder/index.vue'), meta: { title: '离职交接单', public: dev } },
   { path: '/resignation-handover-table', name: 'resignationHandoverTable', component: () => import('@/views/resignationHandoverTable/index.vue'), meta: { title: '离职交接表', public: dev } },
-  { path: '/resignation-certificate', name: 'resignationCertificate', component: () => import('@/views/resignationCertificate/index.vue'), meta: { title: '离职证明', public: dev } }
+  { path: '/resignation-certificate', name: 'resignationCertificate', component: () => import('@/views/resignationCertificate/index.vue'), meta: { title: '离职证明', public: dev } },
+  { path: '/contract-template', name: 'contractTemplate', component: () => import('@/views/contractTemplate/index.vue'), meta: { title: '合同模版', public: dev } },
+  { path: '/contract-management', name: 'contractManagement', component: () => import('@/views/contractManagement/index.vue'), meta: { title: '合同管理', public: dev } },
+  { path: '/attendance/rule', name: 'attendanceRule', component: () => import('@/views/attendance/rule/index.vue'), meta: { title: '打卡规则设定', public: dev } },
+  { path: '/attendance/report', name: 'attendanceReport', component: () => import('@/views/attendance/report/index.vue'), meta: { title: '考勤统计', public: dev } }
 ];
 export default function createRouter(routerBase) {
   const router = new VueRouter({ mode: 'history', base: window.__POWERED_BY_QIANKUN__ ? routerBase || process.env.VUE_APP_QIANKUN_ACTIVE_RULE : process.env.VUE_APP_PUBLIC_PATH, routes, scrollBehavior: () => ({ x: 0, y: 0 }) });
@@ -45,4 +49,4 @@ export default function createRouter(routerBase) {
     next();
   });
   return router;
-}
+}

+ 107 - 0
src/styles/views/attendance/index.scss

@@ -0,0 +1,107 @@
+.attendance-page,.attendance-page *{box-sizing:border-box}
+.attendance-page{min-height:100%;padding:22px;background:#f3f6fb;color:#22334a;--primary:#1768e5}
+.attendance-page .page-hero{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px}
+.attendance-page .eyebrow{color:#1768e5;font-size:10px;letter-spacing:1.8px;font-weight:700}
+.attendance-page .page-hero h1{margin:5px 0;font-size:25px}
+.attendance-page .page-hero p{margin:0;color:#7e8b9d;font-size:12px}
+.attendance-page .hero-actions,.attendance-page .filter-actions{display:flex;gap:8px}
+.attendance-page .metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:16px}
+.attendance-page .metric-card{min-height:98px;padding:15px;display:flex;align-items:center;gap:13px;border:1px solid #e1e8f1;border-radius:12px;background:#fff;color:inherit;text-align:left;cursor:pointer;transition:.2s;box-shadow:0 7px 20px rgba(40,67,106,.05)}
+.attendance-page .metric-card:hover{transform:translateY(-2px);border-color:#b9cff1;box-shadow:0 12px 26px rgba(40,67,106,.1)}
+.attendance-page .metric-icon{width:43px;height:43px;display:grid;place-items:center;border-radius:10px;font-size:20px;flex:none}
+.attendance-page .metric-copy{flex:1;min-width:0}
+.attendance-page .metric-copy small{display:block;color:#7e8b9d;font-size:11px}
+.attendance-page .metric-copy strong{display:block;margin:4px 0;font-size:25px}
+.attendance-page .metric-copy em{margin-left:3px;color:#8d99a9;font-size:10px;font-style:normal;font-weight:400}
+.attendance-page .metric-copy p{margin:4px 0 0;color:#94a1b1;font-size:11px}
+.attendance-page .metric-card>i:last-child{margin-left:auto;color:#bcc7d3}
+.attendance-page .tone-blue .metric-icon{color:#1768e5;background:#eaf2ff}
+.attendance-page .tone-green .metric-icon{color:#168d69;background:#e7f7f1}
+.attendance-page .tone-amber .metric-icon{color:#d58128;background:#fff2e4}
+.attendance-page .tone-red .metric-icon{color:#d55752;background:#fff0ef}
+.attendance-page .tone-purple .metric-icon{color:#7655bc;background:#f1edff}
+.attendance-page .tone-cyan .metric-icon{color:#1b8eb1;background:#e4f6fa}
+.attendance-page .workspace-card{overflow:hidden;border:1px solid #e0e7f1;border-radius:13px;background:#fff;box-shadow:0 7px 22px rgba(40,67,106,.05)}
+.attendance-page .workspace-card ::v-deep .ele-table-tool{min-height:60px;padding:14px 18px;border-bottom:1px solid #e8edf3}
+.attendance-page .workspace-card ::v-deep .ele-table-tool-title-label h6{margin:0;font-size:15px;color:#26364c}
+.attendance-page .workspace-card ::v-deep .ele-table-tool-title-label>div{margin-top:3px;color:#8d99a9;font-size:10px}
+.attendance-page .workspace-card ::v-deep th.el-table__cell{height:48px;padding:0;color:#68778c;background:#f6f8fb;font-weight:600}
+.attendance-page .workspace-card ::v-deep td.el-table__cell{padding:9px 0;border-bottom-color:#edf0f3}
+.attendance-page .workspace-card ::v-deep .el-table__row:hover>td.el-table__cell{background:#f2f7ff!important}
+.attendance-page .status-tabs{display:flex;gap:4px;padding:10px 16px;border-bottom:1px solid #edf1f6;background:#fbfcfe}
+.attendance-page .status-tabs button{height:36px;padding:0 14px;border:0;border-radius:6px;color:#6e7d91;background:transparent;cursor:pointer}
+.attendance-page .status-tabs button:hover{color:#1768e5;background:#f1f6ff}
+.attendance-page .status-tabs button.active{color:#fff;background:#1768e5;box-shadow:0 5px 12px rgba(23,104,229,.18)}
+.attendance-page .status-tabs span{margin-left:5px;padding:2px 6px;border-radius:9px;background:rgba(126,141,162,.12);font-size:10px}
+.attendance-page .status-tabs button.active span{background:rgba(255,255,255,.2)}
+.attendance-page .filter-bar{padding:15px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:11px;background:#fafbfd;border-bottom:1px solid #edf1f6}
+.attendance-page .filter-bar.filter-bar--basic{grid-template-columns:1.4fr 1fr 1fr 1fr auto}
+.attendance-page .filter-bar.filter-bar--shift{grid-template-columns:1.5fr 1fr 1fr auto}
+.attendance-page .filter-actions{justify-content:flex-end;align-items:end}
+.attendance-page .status-pill{display:inline-flex;align-items:center;gap:6px;font-size:11px;white-space:nowrap}
+.attendance-page .status-pill i{width:7px;height:7px;border-radius:50%;background:#aab4c0}
+.attendance-page .status-pill.pending{color:#c67d23}
+.attendance-page .status-pill.pending i{background:#e6a23c}
+.attendance-page .status-pill.processing{color:#1768e5}
+.attendance-page .status-pill.processing i{background:#4c8be8}
+.attendance-page .status-pill.success{color:#168d69}
+.attendance-page .status-pill.success i{background:#22a477}
+.attendance-page .status-pill.danger{color:#d55752}
+.attendance-page .status-pill.danger i{background:#d85e58}
+.attendance-page .status-pill.muted{color:#8590a0}
+.attendance-page .status-pill.muted i{background:#aab4c0}
+.attendance-page .tag-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500}
+.attendance-page .tag-pill.tag-blue{color:#1768e5;background:#eaf2ff}
+.attendance-page .tag-pill.tag-green{color:#168d69;background:#e7f7f1}
+.attendance-page .tag-pill.tag-amber{color:#b27618;background:#fff2e4}
+.attendance-page .tag-pill.tag-red{color:#d55752;background:#fff0ef}
+.attendance-page .tag-pill.tag-purple{color:#7655bc;background:#f1edff}
+.attendance-page .tag-pill.tag-gray{color:#6e7d91;background:#eef2f7}
+.attendance-page .tag-pill.tag-cyan{color:#1b8eb1;background:#e4f6fa}
+.attendance-page .empty-state{min-height:200px;display:grid;place-content:center;justify-items:center;color:#8a96a7}
+.attendance-page .empty-state>i{width:48px;height:48px;display:grid;place-items:center;border-radius:10px;color:#1768e5;background:#edf4ff;font-size:22px}
+.attendance-page .empty-state strong{margin-top:12px;color:#415069;font-size:13px}
+.attendance-page .empty-state p{margin:5px 0 12px;font-size:11px}
+.attendance-page .section-heading{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:14px}
+.attendance-page .section-heading h2{margin:0;font-size:16px;color:#25364e}
+.attendance-page .section-heading p{margin:4px 0 0;color:#8a96a7;font-size:11px}
+.attendance-page .modal ::v-deep .el-dialog__header{padding:16px 22px 12px;border-bottom:1px solid #eaeef4}
+.attendance-page .modal-title{display:flex;align-items:center;gap:11px}
+.attendance-page .modal-title>span{width:36px;height:36px;display:grid;place-items:center;border-radius:9px;color:#1768e5;background:#eaf2ff;font-size:18px}
+.attendance-page .modal-title h3{margin:0;font-size:16px}
+.attendance-page .modal-title p{margin:4px 0 0;color:#8a96a7;font-size:10px}
+.attendance-page .modal ::v-deep .el-dialog__body{padding:18px 22px 8px}
+.attendance-page .modal ::v-deep .el-dialog__footer{padding:14px 22px;border-top:1px solid #eaeef4}
+.attendance-page .form-section{padding:14px;border:1px solid #e1e7ef;border-radius:8px;background:#fff}
+.attendance-page .form-section+.form-section{margin-top:14px}
+.attendance-page .form-section header{display:flex;align-items:center;gap:9px;margin-bottom:14px}
+.attendance-page .form-section header>span{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;color:#1768e5;background:#edf4ff}
+.attendance-page .form-section header h3{margin:0;font-size:14px}
+.attendance-page .form-section header p{margin:4px 0 0;color:#8a96a7;font-size:10px}
+.attendance-page .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
+.attendance-page .form-grid.form-grid--three{grid-template-columns:repeat(3,1fr)}
+.attendance-page .modal ::v-deep .el-form-item__label{padding-bottom:4px;color:#4a586d;font-size:11px;line-height:20px}
+.attendance-page .modal ::v-deep .el-select,.attendance-page .modal ::v-deep .el-date-editor,.attendance-page .modal ::v-deep .el-input-number{width:100%}
+.attendance-page .modal ::v-deep .el-radio+.el-radio{margin-left:14px}
+.attendance-page .modal ::v-deep .el-radio{color:#3d4a5e;font-size:12px}
+.attendance-page .modal ::v-deep .el-checkbox+.el-checkbox{margin-left:14px}
+.attendance-page .modal ::v-deep .el-checkbox{color:#3d4a5e;font-size:12px}
+.attendance-page .modal ::v-deep .el-switch__label{color:#3d4a5e;font-size:12px}
+.attendance-page .info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 18px}
+.attendance-page .info-grid>div>span{display:block;color:#8995a5;font-size:10px}
+.attendance-page .info-grid>div>strong{display:block;margin-top:3px;color:#2b3a52;font-size:12px;font-weight:500}
+.attendance-page .info-grid--two{grid-template-columns:repeat(2,1fr)}
+.attendance-page .info-grid--four{grid-template-columns:repeat(4,1fr)}
+.attendance-page .action-cell{display:inline-flex;align-items:center;gap:6px}
+.attendance-page .action-cell .el-button+.el-button{margin-left:0}
+.attendance-page .name-cell{max-width:100%;padding:0;border:0;background:none;text-align:left;cursor:pointer}
+.attendance-page .name-cell strong,.attendance-page .name-cell small{display:block}
+.attendance-page .name-cell strong{color:#24344b;font-size:13px}
+.attendance-page .name-cell small{margin-top:4px;color:#8d99a9;font-size:10px}
+.attendance-page .name-cell:hover strong{color:#1768e5}
+.attendance-page .two-line strong,.attendance-page .two-line small{display:block}
+.attendance-page .two-line strong{color:#344259;font-size:12px;font-weight:500}
+.attendance-page .two-line small{margin-top:3px;color:#8d99a9;font-size:10px}
+@media(max-width:1100px){.attendance-page .metric-grid{grid-template-columns:1fr 1fr}.attendance-page .filter-bar{grid-template-columns:1fr 1fr}}
+@media(max-width:620px){.attendance-page{padding:14px}.attendance-page .page-hero{align-items:flex-start;gap:12px;flex-direction:column}.attendance-page .metric-grid,.attendance-page .form-grid,.attendance-page .form-grid--three,.attendance-page .info-grid,.attendance-page .info-grid--two,.attendance-page .info-grid--four{grid-template-columns:1fr}.attendance-page .filter-bar{grid-template-columns:1fr}.attendance-page .modal ::v-deep .el-dialog{width:94%!important}.attendance-page .modal ::v-deep .el-dialog__body{padding:14px}}
+

+ 86 - 0
src/views/attendance/components/RuleAssistantPanel.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="tab-panel">
+    <el-form :model="form" label-position="top">
+      <section class="form-section">
+        <header>
+          <h3>助理管理</h3>
+          <p>对助理管理打卡规则进行细粒度配置</p>
+        </header>
+        <el-form-item label="启用助理管理">
+          <el-switch v-model="form.assistantEnabled" active-text="开启" inactive-text="关闭" />
+        </el-form-item>
+        <template v-if="form.assistantEnabled">
+          <el-form-item label="无需打卡员工">
+            <el-select v-model="form.assistantExemptUsers" multiple collapse-tags placeholder="员工列表" style="width:100%">
+              <el-option v-for="u in assistantUserOptions" :key="u" :label="u" :value="u" />
+            </el-select>
+            <span class="form-hint">无需按规则打卡,不计为异常</span>
+          </el-form-item>
+          <el-form-item label="时区">
+            <el-select v-model="form.timezone" placeholder="选择时区" style="width:100%">
+              <el-option v-for="tz in timezoneOptions" :key="tz" :label="tz" :value="tz" />
+            </el-select>
+            <span class="form-hint">成员将按照设置的时区进行打卡和统计,保存后不支持修改</span>
+          </el-form-item>
+          <div class="assistant-row">
+            <el-form-item label="打卡提醒">
+              <el-select v-model="form.reminderOffset" placeholder="选择打卡提醒" style="width:100%">
+                <el-option v-for="r in reminderOptions" :key="r" :label="r" :value="r" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="范围外打卡">
+              <el-select v-model="form.asstOutOfRange" placeholder="选择范围外处理" style="width:100%">
+                <el-option label="记录为地点异常" value="记录为地点异常" />
+                <el-option label="允许打卡" value="允许打卡" />
+                <el-option label="不允许打卡" value="不允许打卡" />
+              </el-select>
+            </el-form-item>
+          </div>
+          <el-form-item label="拍照与人脸识别">
+            <el-select v-model="form.faceRecognition" placeholder="选择识别方式" style="width:100%">
+              <el-option label="启用" value="启用" />
+              <el-option label="禁用" value="禁用" />
+              <el-option label="仅异常时启用" value="仅异常时启用" />
+            </el-select>
+          </el-form-item>
+        </template>
+      </section>
+
+      <template v-if="form.assistantEnabled">
+        <section class="form-section">
+          <header>
+            <h3>打卡设置</h3>
+            <p>对员工每次打卡的方式进行细粒度控制</p>
+          </header>
+          <el-form-item label="每次打卡均需拍照">
+            <el-switch v-model="form.photoEachPunch" />
+          </el-form-item>
+          <el-form-item label="每次打卡均需人脸识别">
+            <el-switch v-model="form.faceEachPunch" />
+          </el-form-item>
+        </section>
+        <section class="form-section">
+          <header>
+            <h3>外出打卡记录同步</h3>
+          </header>
+          <el-form-item label="外出打卡记录同步至上下班打卡">
+            <el-switch v-model="form.outsourceSync" />
+            <span class="form-hint">开启后,员工上下班期内外出打卡记录将同步至上下班中,结果显示为「外出打卡」</span>
+          </el-form-item>
+        </section>
+      </template>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleAssistantPanel',
+  props: {
+    form: { type: Object, required: true },
+    assistantUserOptions: { type: Array, default: () => [] },
+    timezoneOptions: { type: Array, default: () => [] },
+    reminderOptions: { type: Array, default: () => [] },
+  },
+};
+</script>

+ 86 - 0
src/views/attendance/components/RuleFormPanels.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="rule-form-col">
+    <rule-method-panel
+      v-if="activeTab === 'method'"
+      :form="form"
+      :rules="rules"
+      @add-location="$emit('add-location')"
+      @add-wifi="$emit('add-wifi')"
+      @add-device="$emit('add-device')"
+      @device-view="(row) => $emit('device-view', row)"
+      @device-edit="(row) => $emit('device-edit', row)"
+      @device-test="(row) => $emit('device-test', row)"
+      @device-sync="(row) => $emit('device-sync', row)"
+      @device-restart="(row) => $emit('device-restart', row)"
+      @device-delete="(row) => $emit('device-delete', row)"
+      @device-log="(row) => $emit('device-log', row)"
+    />
+    <rule-work-time-panel
+      v-else-if="activeTab === 'workTime'"
+      :form="form"
+      :week-options="weekOptions"
+      :time-options="timeOptions"
+      :related-target-options="relatedTargetOptions"
+    />
+    <rule-rest-panel v-else-if="activeTab === 'rest'" />
+    <rule-overtime-panel
+      v-else-if="activeTab === 'overtime'"
+      :form="form"
+    />
+    <rule-makeup-panel
+      v-else-if="activeTab === 'makeup'"
+      :form="form"
+    />
+    <rule-leave-panel
+      v-else-if="activeTab === 'leave'"
+      :form="form"
+    />
+    <rule-assistant-panel
+      v-else-if="activeTab === 'assistant'"
+      :form="form"
+      :assistant-user-options="assistantUserOptions"
+      :timezone-options="timezoneOptions"
+      :reminder-options="reminderOptions"
+    />
+    <rule-outside-panel
+      v-else-if="activeTab === 'outside'"
+      :form="form"
+    />
+  </div>
+</template>
+
+<script>
+import RuleMethodPanel from './RuleMethodPanel.vue';
+import RuleWorkTimePanel from './RuleWorkTimePanel.vue';
+import RuleRestPanel from './RuleRestPanel.vue';
+import RuleOvertimePanel from './RuleOvertimePanel.vue';
+import RuleMakeupPanel from './RuleMakeupPanel.vue';
+import RuleLeavePanel from './RuleLeavePanel.vue';
+import RuleAssistantPanel from './RuleAssistantPanel.vue';
+import RuleOutsidePanel from './RuleOutsidePanel.vue';
+
+export default {
+  name: 'RuleFormPanels',
+  components: {
+    RuleMethodPanel,
+    RuleWorkTimePanel,
+    RuleRestPanel,
+    RuleOvertimePanel,
+    RuleMakeupPanel,
+    RuleLeavePanel,
+    RuleAssistantPanel,
+    RuleOutsidePanel,
+  },
+  props: {
+    form: { type: Object, required: true },
+    activeTab: { type: String, default: 'method' },
+    weekOptions: { type: Array, default: () => [] },
+    timeOptions: { type: Array, default: () => [] },
+    relatedTargetOptions: { type: Array, default: () => [] },
+    assistantUserOptions: { type: Array, default: () => [] },
+    timezoneOptions: { type: Array, default: () => [] },
+    reminderOptions: { type: Array, default: () => [] },
+    rules: { type: Object, default: () => ({}) },
+  },
+};
+</script>

+ 90 - 0
src/views/attendance/components/RuleLeavePanel.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="tab-panel">
+    <el-form :model="form" label-position="top">
+      <section class="form-section">
+        <header>
+          <h3>请假时离岗/返岗需打卡</h3>
+          <p>员工请假离岗和返岗时是否需要打卡</p>
+        </header>
+        <el-form-item label="请假时离岗/返岗需打卡">
+          <el-switch v-model="form.leaveNeedPunch" active-text="开启" inactive-text="关闭" />
+        </el-form-item>
+        <el-form-item label="打卡时间">
+          <el-select v-model="form.leavePunchWindow" placeholder="选择打卡时间" style="width:240px">
+            <el-option label="不限制" value="不限制" />
+            <el-option label="上下班前后 30 分钟" value="上下班前后 30 分钟" />
+            <el-option label="上下班前后 1 小时" value="上下班前后 1 小时" />
+            <el-option label="仅请假开始时间" value="仅请假开始时间" />
+            <el-option label="仅请假结束时间" value="仅请假结束时间" />
+          </el-select>
+        </el-form-item>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>满足公司福利条件-设置假制度</h3>
+          <p>为不同假期类型设置年度额度、薪资规则与审批流程</p>
+        </header>
+        <el-table :data="form.leaveRules" class="leave-rule-table" size="small" border stripe>
+          <el-table-column type="index" label="序号" width="60" align="center" header-align="center" />
+          <el-table-column prop="category" label="假期分类" width="90" align="center" />
+          <el-table-column prop="name" label="假期名称" width="100" align="center" />
+          <el-table-column prop="type" label="假期类型" width="110" align="center" />
+          <el-table-column prop="scope" label="适用人员" width="100" align="center" />
+          <el-table-column prop="condition" label="满足条件" width="140" />
+          <el-table-column prop="quota" label="年度额度" width="90" align="center" />
+          <el-table-column prop="salary" label="薪资规则" width="130" />
+          <el-table-column prop="approval" label="审批流程" width="80" align="center">
+            <template slot-scope="{ row }">
+              <span class="status-pill" :class="row.approval === '已审核' ? 'success' : 'muted'">
+                <i></i>{{ row.approval }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="status" label="状态" width="80" align="center">
+            <template slot-scope="{ row }">
+              <span class="status-pill" :class="row.status === '已生效' ? 'success' : (row.status === '已归档' ? 'muted' : 'pending')">
+                <i></i>{{ row.status }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="90" align="center" fixed="right">
+            <template slot-scope="{ row, $index }">
+              <el-button type="text" size="mini" :class="row.action === '已归档' ? 'is-archived' : ''" @click="onLeaveRuleAction(row, $index)">{{ row.action }}</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </section>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleLeavePanel',
+  props: {
+    form: { type: Object, required: true },
+  },
+  methods: {
+    onLeaveRuleAction(row, index) {
+      if (row.action === '已归档') {
+        this.$confirm('确定恢复该假期制度为可编辑?', '提示', { type: 'warning' })
+          .then(() => {
+            this.$set(this.form.leaveRules[index], 'action', '修改');
+            this.$set(this.form.leaveRules[index], 'approval', '待审核');
+            this.$set(this.form.leaveRules[index], 'status', '未生效');
+            this.$message.success('已恢复为可编辑');
+          }).catch(() => {});
+      } else {
+        this.$prompt('请输入新假期名称', '修改假期制度', { confirmButtonText: '保存', cancelButtonText: '取消' })
+          .then(({ value }) => {
+            if (value) {
+              this.$set(this.form.leaveRules[index], 'name', value);
+              this.$message.success('已修改');
+            }
+          }).catch(() => {});
+      }
+    },
+  },
+};
+</script>

+ 89 - 0
src/views/attendance/components/RuleMakeupPanel.vue

@@ -0,0 +1,89 @@
+<template>
+  <div class="tab-panel">
+    <el-form :model="form" label-position="top">
+      <section class="form-section">
+        <header>
+          <h3>补卡规则</h3>
+          <p>员工异常打卡时可提交申请,审批通过后修正异常</p>
+        </header>
+        <el-form-item label="补卡规则">
+          <el-switch v-model="form.makeupEnabled" active-text="启用" inactive-text="关闭" />
+        </el-form-item>
+        <template v-if="form.makeupEnabled">
+          <el-form-item label="允许提交补卡申请">
+            <span class="field-hint">员工异常打卡时可提交申请,审批通过后修正异常</span>
+          </el-form-item>
+          <el-form-item label="补卡类型">
+            <el-select v-model="form.makeupType" placeholder="选择补卡类型" style="width:100%">
+              <el-option label="缺卡/旷工" value="缺卡/旷工" />
+              <el-option label="迟到/早退" value="迟到/早退" />
+              <el-option label="其他异常" value="其他异常" />
+              <el-option label="正常" value="正常" />
+            </el-select>
+          </el-form-item>
+          <div class="makeup-row">
+            <el-form-item label="允许补卡时间限制">
+              <el-select v-model="form.makeupTimeLimit" placeholder="选择时间限制" style="width:100%">
+                <el-option label="不限制" value="不限制" />
+                <el-option label="异常后 1 天内" value="异常后 1 天内" />
+                <el-option label="异常后 3 天内" value="异常后 3 天内" />
+                <el-option label="异常后 7 天内" value="异常后 7 天内" />
+                <el-option label="当月内" value="当月内" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="补卡提醒">
+              <el-select v-model="form.makeupReminder" placeholder="选择提醒" style="width:120px">
+                <el-option label="启用" value="启用" />
+                <el-option label="禁用" value="禁用" />
+              </el-select>
+              <el-time-picker v-if="form.makeupReminder === '启用'" v-model="form.makeupReminderTime" placeholder="提醒时间" value-format="HH:mm" format="HH:mm" style="width:140px" />
+              <span class="form-hint">将予设定时间提醒成员补卡</span>
+            </el-form-item>
+          </div>
+          <div class="makeup-row">
+            <el-form-item label="每月允许补卡次数">
+              <el-select v-model="form.makeupMaxPerMonth" placeholder="选择次数" style="width:100%">
+                <el-option label="不限" value="不限" />
+                <el-option label="1次" value="1次" />
+                <el-option label="2次" value="2次" />
+                <el-option label="3次" value="3次" />
+                <el-option label="5次" value="5次" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="每月补卡截止日期">
+              <el-select v-model="form.makeupDeadline" placeholder="选择截止日期" style="width:100%">
+                <el-option label="不设置" value="不设置" />
+                <el-option label="当月最后一天" value="当月最后一天" />
+                <el-option label="次月 5 日" value="次月 5 日" />
+                <el-option label="次月 10 日" value="次月 10 日" />
+              </el-select>
+              <span class="form-hint">补卡截止日期为零点开始,不可对上月异常打卡提交补卡申请</span>
+            </el-form-item>
+          </div>
+        </template>
+      </section>
+
+      <template v-if="form.makeupEnabled">
+        <section class="form-section">
+          <header>
+            <h3>审批打卡规则</h3>
+            <p>定位不准等原因无法打卡时,可提交审批打卡</p>
+          </header>
+          <el-form-item label="允许提交审批打卡申请">
+            <el-switch v-model="form.approvalPunchEnabled" active-text="启用" inactive-text="关闭" />
+            <span class="form-hint">定位不准等原因无法打卡时,可提交审批打卡</span>
+          </el-form-item>
+        </section>
+      </template>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleMakeupPanel',
+  props: {
+    form: { type: Object, required: true },
+  },
+};
+</script>

+ 116 - 0
src/views/attendance/components/RuleMethodPanel.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="tab-panel">
+    <el-form ref="form" :model="form" :rules="rules" label-position="top" class="rule-form">
+      <section class="form-section">
+        <header>
+          <h3><span class="required">*</span>打卡方式</h3>
+          <p>手机和考勤机满足任意一项即可打卡</p>
+        </header>
+        <el-form-item label="选择方式" prop="method">
+          <el-radio-group v-model="form.method" class="method-radio">
+            <el-radio label="phone">手机</el-radio>
+            <el-radio label="device">考勤机</el-radio>
+            <el-radio label="phone-device">手机+考勤机</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </section>
+      <section class="form-section">
+        <header>
+          <h3>打卡位置</h3>
+          <p>设置打卡地点范围(GPS / 客户现场)</p>
+        </header>
+        <el-button size="small" icon="el-icon-plus" @click="$emit('add-location')">添加</el-button>
+        <el-table :data="form.locations" class="location-table" size="small" empty-text="暂未添加打卡位置">
+          <el-table-column prop="name" label="地点名称" minWidth="220" />
+          <el-table-column prop="range" label="有效范围" width="160" />
+          <el-table-column label="操作" width="80" align="center">
+            <template slot-scope="{ row, $index }">
+              <el-button type="text" size="mini" class="is-danger"
+                @click="form.locations.splice($index, 1)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <el-form-item label="范围外打卡" class="range-form-item">
+          <el-select v-model="form.outOfRange" placeholder="选择范围外打卡策略" style="width:240px">
+            <el-option label="记录为地点异常" value="异常" />
+            <el-option label="允许打卡" value="允许" />
+            <el-option label="不允许打卡" value="不允许" />
+          </el-select>
+        </el-form-item>
+      </section>
+      <section class="form-section">
+        <header>
+          <h3>打卡Wi-Fi</h3>
+          <p>绑定办公网络 Wi-Fi</p>
+        </header>
+        <el-button size="small" icon="el-icon-plus" @click="$emit('add-wifi')">添加</el-button>
+        <el-table :data="form.wifis" class="location-table" size="small" empty-text="暂未添加打卡Wi-Fi">
+          <el-table-column prop="ssid" label="WiFi 名称" minWidth="200" />
+          <el-table-column prop="bssid" label="MAC 地址" minWidth="200" />
+          <el-table-column label="操作" width="80" align="center">
+            <template slot-scope="{ row, $index }">
+              <el-button type="text" size="mini" class="is-danger"
+                @click="form.wifis.splice($index, 1)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="wifi-tip">
+          <h4>获取方式</h4>
+          <ol>
+            <li>此页面会自动采集当前连接 Wi-Fi 的 BSSID</li>
+            <li>若公司有多个 Wi-Fi,请依次手动连接到各个 Wi-Fi,再切换到此页面来采集数据</li>
+            <li>采集完成后,选择所需 Wi-Fi 并提交</li>
+            <li>请确保 Wi-Fi 路由器 BSSID 不会动态变化,否则可能导致连上 Wi-Fi 但提示"不在打卡范围内"等异常</li>
+          </ol>
+        </div>
+      </section>
+
+      <section v-if="form.method !== 'phone'" class="form-section">
+        <header>
+          <h3>考勤设备</h3>
+          <p>添加并管理用于打卡的考勤机设备</p>
+        </header>
+        <el-button size="small" icon="el-icon-plus" @click="$emit('add-device')">添加考勤机</el-button>
+        <el-table :data="form.devices" class="location-table device-table" size="small"
+          empty-text="暂未添加考勤设备,点击右上角“添加考勤机”按钮添加">
+          <el-table-column prop="name" label="设备名称" minWidth="120" />
+          <el-table-column prop="sn" label="SN码" minWidth="140" />
+          <el-table-column prop="ip" label="IP地址" minWidth="120" />
+          <el-table-column prop="deviceType" label="设备类型" minWidth="110" />
+          <el-table-column label="在线状态" width="100" align="center">
+            <template slot-scope="{ row }">
+              <span class="status-dot" :class="row.online ? 'on' : 'off'">
+                <i></i>{{ row.online ? '在线' : '离线' }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="onlineDuration" label="在线时长" width="110" align="center" />
+          <el-table-column prop="department" label="绑定部门" minWidth="140" />
+          <el-table-column prop="lastSync" label="最后同步时间" width="160" />
+          <el-table-column label="操作" minWidth="280" align="left">
+            <template slot-scope="{ row, $index }">
+              <el-button type="text" size="mini" @click="$emit('device-view', row)">查看详情</el-button>
+              <el-button type="text" size="mini" @click="$emit('device-edit', row)">编辑</el-button>
+              <el-button type="text" size="mini" @click="$emit('device-test', row)">连接测试</el-button>
+              <el-button type="text" size="mini" @click="$emit('device-sync', row)">同步数据</el-button>
+              <el-button type="text" size="mini" @click="$emit('device-restart', row)">重启设备</el-button>
+              <el-button type="text" size="mini" class="is-danger"
+                @click="$emit('device-delete', row)">删除</el-button>
+              <el-button type="text" size="mini" @click="$emit('device-log', row)">日志</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </section>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleMethodPanel',
+  props: {
+    form: { type: Object, required: true },
+    rules: { type: Object, default: () => ({}) },
+  },
+};
+</script>

+ 63 - 0
src/views/attendance/components/RuleOutsidePanel.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="tab-panel">
+    <el-form :model="form" label-position="top">
+      <section class="form-section">
+        <header>
+          <h3>外出打卡</h3>
+          <p>外勤 / 客户拜访现场打卡</p>
+        </header>
+        <el-form-item label="启用外出打卡">
+          <el-switch v-model="form.outsideEnabled" active-text="开启" inactive-text="关闭" />
+        </el-form-item>
+        <template v-if="form.outsideEnabled">
+          <el-form-item label="外出打卡方式" prop="outsideMode">
+            <el-radio-group v-model="form.outsideMode" class="outside-mode">
+              <el-radio label="time">上班打卡时间</el-radio>
+              <el-radio label="location">定位打卡</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <div v-if="form.outsideMode === 'time'" class="outside-subfield">
+            <span class="outside-subfield-label">打卡时段</span>
+            <div class="time-pair">
+              <el-time-picker v-model="form.outsideStart" placeholder="开始时间" value-format="HH:mm" format="HH:mm" style="width:140px" />
+              <span class="dash">—</span>
+              <el-time-picker v-model="form.outsideEnd" placeholder="结束时间" value-format="HH:mm" format="HH:mm" style="width:140px" />
+            </div>
+          </div>
+          <div v-else-if="form.outsideMode === 'location'" class="outside-subfield outside-subfield--row">
+            <span class="outside-subfield-label">定位打卡</span>
+            <el-select v-model="form.outsideAddress" placeholder="不限制定打卡地址" style="width:240px">
+              <el-option label="不限制定打卡地址" value="不限制" />
+              <el-option label="仅限指定地址" value="限制定" />
+              <el-option label="限制地理围栏" value="围栏" />
+            </el-select>
+            <span class="outside-photo-label">是否需要拍照</span>
+            <el-switch v-model="form.outsidePhoto" active-text="开启" inactive-text="关闭" />
+          </div>
+        </template>
+      </section>
+      <section class="form-section">
+        <header>
+          <h3>汇报提醒时间</h3>
+          <p>汇报对象将收到前一日向自己汇报的人的外出打卡汇总</p>
+        </header>
+        <el-form-item label="汇报提醒时间">
+          <div class="time-pair">
+            <el-time-picker v-model="form.reportStart" placeholder="开始时间" value-format="HH:mm" format="HH:mm" style="width:140px" />
+            <span class="dash">—</span>
+            <el-time-picker v-model="form.reportEnd" placeholder="结束时间" value-format="HH:mm" format="HH:mm" style="width:140px" />
+          </div>
+        </el-form-item>
+      </section>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleOutsidePanel',
+  props: {
+    form: { type: Object, required: true },
+  },
+};
+</script>

+ 159 - 0
src/views/attendance/components/RuleOvertimePanel.vue

@@ -0,0 +1,159 @@
+<template>
+  <div class="tab-panel">
+    <el-form :model="form" label-position="top">
+      <section class="form-section">
+        <header>
+          <h3>加班基础配置</h3>
+          <p>设置加班时长单位、精度与换算规则</p>
+        </header>
+        <div class="overtime-row">
+          <el-form-item label="加班时长单位">
+            <el-select v-model="form.overtimeUnit" placeholder="选择单位" style="width:100%">
+              <el-option label="小时" value="小时" />
+              <el-option label="天" value="天" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="加班时长取整方式">
+            <el-select v-model="form.overtimeRound" placeholder="选择取整" style="width:100%">
+              <el-option label="四舍五入" value="四舍五入" />
+              <el-option label="向上取整" value="向上取整" />
+              <el-option label="向下取整" value="向下取整" />
+              <el-option label="不取整" value="不取整" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="保留小数至">
+            <el-select v-model="form.overtimeDecimal" placeholder="选择小数位" style="width:100%">
+              <el-option label="0位整数" value="0位整数" />
+              <el-option label="1位小数" value="1位小数" />
+              <el-option label="2位小数" value="2位小数" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <el-form-item label="加班单位换算按 1 天 =">
+          <el-input-number v-model="form.overtimeDayHours" :min="1" :max="24" :step="1" style="width:120px" />
+          <span class="form-hint">小时</span>
+        </el-form-item>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>工作日加班规则</h3>
+          <p>设置工作日(周一至周五)的加班规则</p>
+        </header>
+        <div class="overtime-period-row">
+          <el-form-item label="允许加班">
+            <el-switch v-model="form.workOvertimeEnabled" />
+          </el-form-item>
+          <el-form-item label="加班时段" v-if="form.workOvertimeEnabled">
+            <span class="period-label">允许加班的时段</span>
+            <el-select v-model="form.workOvertimePeriod" placeholder="时间段选择" style="width:160px">
+              <el-option label="下班后到次日上班前" value="下班后到次日上班前" />
+              <el-option label="午休时段" value="午休时段" />
+              <el-option label="所有非工作时间" value="所有非工作时间" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <template v-if="form.workOvertimeEnabled">
+          <el-form-item label="加班时长计算方式">
+            <el-select v-model="form.workCalcMethod" placeholder="选择计算方式" style="width:200px">
+              <el-option label="按打卡时长计算" value="按打卡时长计算" />
+              <el-option label="按审批时长计算" value="按审批时长计算" />
+            </el-select>
+          </el-form-item>
+          <div class="overtime-section-subtitle">计算规则</div>
+          <div class="overtime-toggle-row">
+            <el-form-item label="加班扣除休息时间">
+              <el-switch v-model="form.workDeductRest" />
+            </el-form-item>
+            <el-form-item label="加班时长计算调休或加班费">
+              <el-switch v-model="form.workCompOrPay" />
+            </el-form-item>
+          </div>
+        </template>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>休息日加班规则</h3>
+          <p>设置周六、周日的加班规则</p>
+        </header>
+        <div class="overtime-period-row">
+          <el-form-item label="允许加班">
+            <el-switch v-model="form.restOvertimeEnabled" />
+          </el-form-item>
+          <el-form-item label="加班时段" v-if="form.restOvertimeEnabled">
+            <span class="period-label">允许加班的时段</span>
+            <el-select v-model="form.restOvertimePeriod" placeholder="时间段选择" style="width:160px">
+              <el-option label="全天" value="全天" />
+              <el-option label="工作时段外" value="工作时段外" />
+              <el-option label="所有时段" value="所有时段" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <template v-if="form.restOvertimeEnabled">
+          <el-form-item label="加班时长计算方式">
+            <el-select v-model="form.restCalcMethod" placeholder="选择计算方式" style="width:200px">
+              <el-option label="按打卡时长计算" value="按打卡时长计算" />
+              <el-option label="按审批时长计算" value="按审批时长计算" />
+            </el-select>
+          </el-form-item>
+          <div class="overtime-section-subtitle">计算规则</div>
+          <div class="overtime-toggle-row">
+            <el-form-item label="加班扣除休息时间">
+              <el-switch v-model="form.restDeductRest" />
+            </el-form-item>
+            <el-form-item label="加班时长计算调休或加班费">
+              <el-switch v-model="form.restCompOrPay" />
+            </el-form-item>
+          </div>
+        </template>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>节假日加班规则</h3>
+          <p>设置国家法定节假日的加班规则</p>
+        </header>
+        <div class="overtime-period-row">
+          <el-form-item label="允许加班">
+            <el-switch v-model="form.holidayOvertimeEnabled" />
+          </el-form-item>
+          <el-form-item label="加班时段" v-if="form.holidayOvertimeEnabled">
+            <span class="period-label">允许加班的时段</span>
+            <el-select v-model="form.holidayOvertimePeriod" placeholder="时间段选择" style="width:160px">
+              <el-option label="全天" value="全天" />
+              <el-option label="工作时段外" value="工作时段外" />
+              <el-option label="所有时段" value="所有时段" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <template v-if="form.holidayOvertimeEnabled">
+          <el-form-item label="加班时长计算方式">
+            <el-select v-model="form.holidayCalcMethod" placeholder="选择计算方式" style="width:200px">
+              <el-option label="按打卡时长计算" value="按打卡时长计算" />
+              <el-option label="按审批时长计算" value="按审批时长计算" />
+            </el-select>
+          </el-form-item>
+          <div class="overtime-section-subtitle">计算规则</div>
+          <div class="overtime-toggle-row">
+            <el-form-item label="加班扣除休息时间">
+              <el-switch v-model="form.holidayDeductRest" />
+            </el-form-item>
+            <el-form-item label="加班时长计算调休或加班费">
+              <el-switch v-model="form.holidayCompOrPay" />
+            </el-form-item>
+          </div>
+        </template>
+      </section>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleOvertimePanel',
+  props: {
+    form: { type: Object, required: true },
+  },
+};
+</script>

+ 1213 - 0
src/views/attendance/components/RuleRestPanel.vue

@@ -0,0 +1,1213 @@
+<template>
+  <div class="tab-panel rule-rest-panel">
+    <el-form
+      ref="form"
+      :model="form"
+      :rules="rules"
+      label-position="top"
+    >
+      <!-- 日历基础信息 -->
+      <section class="form-section">
+        <header>
+          <h3>日历基础信息</h3>
+          <p>配置工厂日历的编码、名称、类型、适用年份月份与启停状态</p>
+        </header>
+        <div class="form-grid form-grid--two">
+          <el-form-item label="日历编码">
+            <el-input v-model="form.calendarCode" disabled />
+          </el-form-item>
+          <el-form-item label="日历名称" prop="calendarName">
+            <el-input v-model="form.calendarName" maxlength="128" />
+          </el-form-item>
+          <el-form-item label="日历类型" prop="calendarType">
+            <el-select
+              v-model="form.calendarType"
+              :disabled="!!form.id"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="item in calendarTypeOptions"
+                :key="item.value"
+                :label="item.label"
+                :value="item.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="适用年份" prop="applyYear">
+            <el-select v-model="formApplyYear" style="width: 100%">
+              <el-option
+                v-for="year in yearOptions"
+                :key="year"
+                :label="year"
+                :value="year"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="适用月份" prop="applyMonth">
+            <el-select
+              v-model="form.applyMonth"
+              multiple
+              style="width: 100%"
+              @change="handleApplyMonthChange"
+            >
+              <el-option
+                v-for="month in monthOptions"
+                :key="month.value"
+                :label="month.label"
+                :value="month.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-switch
+              v-model="form.status"
+              :active-value="1"
+              :inactive-value="0"
+              active-text="启用"
+              inactive-text="禁用"
+            />
+          </el-form-item>
+        </div>
+      </section>
+
+      <!-- 周休设置 -->
+      <section class="form-section">
+        <header>
+          <h3>周休设置</h3>
+          <p>选择按周休模式,自定义模式下可勾选任意休息星期</p>
+        </header>
+        <div class="rest-rule-editor">
+          <el-radio-group v-model="form.restMode">
+            <el-radio-button
+              v-for="item in restModeOptions"
+              :key="item.value"
+              :label="item.value"
+            >
+              {{ item.label }}
+            </el-radio-button>
+          </el-radio-group>
+          <el-checkbox-group
+            v-if="form.restMode === 'custom'"
+            v-model="form.restWeekdays"
+            class="custom-rest-weekdays"
+          >
+            <el-checkbox-button
+              v-for="item in restWeekdayOptions"
+              :key="item.value"
+              :label="item.value"
+            >
+              {{ item.label }}
+            </el-checkbox-button>
+          </el-checkbox-group>
+          <div class="rule-tip">
+            双休为周六周日休,单休为周日休,大小周为周日固定休且隔周周六休,自定义可勾选任意休息星期。
+          </div>
+        </div>
+      </section>
+
+      <!-- 法定节假日 -->
+      <section v-if="showLegalHolidaySection" class="form-section">
+        <header>
+          <h3>法定节假日</h3>
+          <p>默认休息,点选日期后按上班处理</p>
+          <span class="form-hint">
+            {{ `${legalHolidaySourceText},已设上班 ${legalHolidayVisibleWorkCount} 天` }}
+          </span>
+        </header>
+        <div v-if="legalHolidayGroups.length" class="legal-holiday-list">
+          <div
+            v-for="group in legalHolidayGroups"
+            :key="group.name"
+            class="legal-holiday-group"
+          >
+            <div class="legal-holiday-group-head">
+              <strong>{{ group.name }}</strong>
+              <span>{{ group.dates.length }}天</span>
+            </div>
+            <div class="legal-holiday-dates">
+              <button
+                v-for="item in group.dates"
+                :key="item.calendarDate"
+                type="button"
+                class="legal-holiday-date"
+                :class="{
+                  'is-work': (form.legalHolidayWorkDates || []).includes(
+                    item.calendarDate
+                  )
+                }"
+                @click="toggleLegalHolidayWorkStatus(item.calendarDate)"
+              >
+                <span>{{ formatHolidayDate(item.calendarDate) }}</span>
+                <em>
+                  {{
+                    (form.legalHolidayWorkDates || []).includes(
+                      item.calendarDate
+                    )
+                      ? '上班'
+                      : '休息'
+                  }}
+                </em>
+              </button>
+            </div>
+          </div>
+        </div>
+      </section>
+
+      <!-- 特殊日期 -->
+      <section class="form-section">
+        <header>
+          <h3>特殊日期</h3>
+          <p>特殊日期优先级高于周休设置,可把周末设为上班,也可把工作日设为休息或法定节假日。</p>
+        </header>
+        <div class="holiday-rule-editor">
+          <div
+            v-for="(rule, index) in form.holidayRules"
+            :key="rule.id"
+            class="holiday-rule-row"
+          >
+            <el-date-picker
+              v-model="rule.calendarDate"
+              type="date"
+              value-format="yyyy-MM-dd"
+              placeholder="选择日期"
+              style="width: 160px"
+            />
+            <el-select
+              v-model="rule.dateType"
+              placeholder="日期类型"
+              style="width: 130px"
+            >
+              <el-option label="上班" :value="1" />
+              <el-option label="休息" :value="2" />
+              <el-option label="法定节假日" :value="3" />
+            </el-select>
+            <el-input
+              v-model="rule.remark"
+              placeholder="备注"
+              maxlength="80"
+              style="flex: 1"
+            />
+            <el-button
+              type="text"
+              icon="el-icon-delete"
+              class="danger-link"
+              @click="removeHolidayRule(index)"
+            >
+              删除
+            </el-button>
+          </div>
+          <el-button size="small" icon="el-icon-plus" @click="addHolidayRule">
+            新增特殊日期
+          </el-button>
+        </div>
+      </section>
+
+      <!-- 备注 -->
+      <section class="form-section">
+        <header>
+          <h3>备注</h3>
+          <p>可选,记录该日历的用途或特殊说明</p>
+        </header>
+        <el-form-item>
+          <el-input
+            v-model="form.remark"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+          />
+        </el-form-item>
+      </section>
+    </el-form>
+  </div>
+</template>
+
+<script>
+  import dayjs from 'dayjs';
+  import {
+    addCalendar,
+    editCalendar,
+    updateCalendarStatus
+  } from '@/api/productionScheduling/factoryCalendar';
+
+  const currentYear = Number(dayjs().format('YYYY'));
+
+  // ===================== 农历/节气节日查找表(不使用第三方依赖) =====================
+  // 春节 = 农历正月初一;端午节 = 农历五月初五;中秋节 = 农历八月十五;
+  // 清明节按节气(约 4/4 或 4/5)。
+  const LUNAR_HOLIDAY_DATES = {
+    春节: {
+      2020: '2020-01-25',
+      2021: '2021-02-12',
+      2022: '2022-02-01',
+      2023: '2023-01-22',
+      2024: '2024-02-10',
+      2025: '2025-01-29',
+      2026: '2026-02-17',
+      2027: '2027-02-06',
+      2028: '2028-01-26',
+      2029: '2029-02-13',
+      2030: '2030-02-03'
+    },
+    端午节: {
+      2020: '2020-06-25',
+      2021: '2021-06-14',
+      2022: '2022-06-03',
+      2023: '2023-06-22',
+      2024: '2024-06-10',
+      2025: '2025-05-31',
+      2026: '2026-06-19',
+      2027: '2027-06-09',
+      2028: '2028-05-28',
+      2029: '2029-06-16',
+      2030: '2030-06-05'
+    },
+    中秋节: {
+      2020: '2020-10-01',
+      2021: '2021-09-21',
+      2022: '2022-09-10',
+      2023: '2023-09-29',
+      2024: '2024-09-17',
+      2025: '2025-10-06',
+      2026: '2026-09-25',
+      2027: '2027-09-15',
+      2028: '2028-10-03',
+      2029: '2029-09-22',
+      2030: '2030-09-12'
+    },
+    清明节: {
+      2020: '2020-04-04',
+      2021: '2021-04-04',
+      2022: '2022-04-05',
+      2023: '2023-04-05',
+      2024: '2024-04-04',
+      2025: '2025-04-04',
+      2026: '2026-04-05',
+      2027: '2027-04-05',
+      2028: '2028-04-04',
+      2029: '2029-04-04',
+      2030: '2030-04-05'
+    }
+  };
+
+  // 公历固定节日(无需查表)
+  const SOLAR_FIXED_HOLIDAYS = {
+    元旦节: { month: 1, day: 1 },
+    劳动节: { month: 5, day: 1 },
+    国庆节: { month: 10, day: 1 }
+  };
+
+  /**
+   * 根据公历日期返回对应中文节日名称,无则返回空串。
+   * 用于替代原 lunar-calendar 的 solarToLunar().solarFestival/lunarFestival/term 三项合并判断。
+   */
+  function getChineseHolidayName(dateItem) {
+    const y = dateItem.year();
+    const m = dateItem.month() + 1;
+    const d = dateItem.date();
+    const dateStr = dateItem.format('YYYY-MM-DD');
+    for (const [name, { month, day }] of Object.entries(SOLAR_FIXED_HOLIDAYS)) {
+      if (m === month && d === day) return name;
+    }
+    for (const [name, yearMap] of Object.entries(LUNAR_HOLIDAY_DATES)) {
+      if (yearMap[y] === dateStr) return name;
+    }
+    return '';
+  }
+
+  // ===================== 常量 =====================
+  const calendarTypeOptions = [
+    { label: '标准生产日历', value: 1 },
+    { label: '设备维护日历', value: 2 },
+    { label: '人员排班日历', value: 3 }
+  ];
+  const yearOptions = [
+    currentYear - 1,
+    currentYear,
+    currentYear + 1,
+    currentYear + 2
+  ];
+  const monthOptions = Array.from({ length: 12 }, (_, index) => ({
+    label: `${index + 1}月`,
+    value: index + 1
+  }));
+  const restModeOptions = [
+    { label: '双休', value: 'double' },
+    { label: '单休', value: 'single' },
+    { label: '大小周', value: 'alternate' },
+    { label: '无休', value: 'none' },
+    { label: '自定义', value: 'custom' }
+  ];
+  const restWeekdayOptions = [
+    { label: '周一', value: 1 },
+    { label: '周二', value: 2 },
+    { label: '周三', value: 3 },
+    { label: '周四', value: 4 },
+    { label: '周五', value: 5 },
+    { label: '周六', value: 6 },
+    { label: '周日', value: 0 }
+  ];
+  const legalHolidayKeywords = [
+    { keyword: '元旦节', label: '元旦节' },
+    { keyword: '春节', label: '春节' },
+    { keyword: '清明', label: '清明节' },
+    { keyword: '劳动节', label: '劳动节' },
+    { keyword: '端午节', label: '端午节' },
+    { keyword: '中秋节', label: '中秋节' },
+    { keyword: '国庆节', label: '国庆节' }
+  ];
+  const officialHolidayRanges = {
+    2026: [
+      { name: '元旦节', start: '2026-01-01', end: '2026-01-03' },
+      { name: '春节', start: '2026-02-15', end: '2026-02-23' },
+      { name: '清明节', start: '2026-04-04', end: '2026-04-06' },
+      { name: '劳动节', start: '2026-05-01', end: '2026-05-05' },
+      { name: '端午节', start: '2026-06-19', end: '2026-06-21' },
+      { name: '中秋节', start: '2026-09-25', end: '2026-09-27' },
+      { name: '国庆节', start: '2026-10-01', end: '2026-10-07' }
+    ]
+  };
+  const inferredHolidayRules = {
+    元旦节: { before: 0, after: 2 },
+    春节: { before: 2, after: 6 },
+    清明节: { before: 1, after: 1 },
+    劳动节: { before: 0, after: 4 },
+    端午节: { before: 0, after: 2 },
+    中秋节: { before: 0, after: 2 },
+    国庆节: { before: 0, after: 6 }
+  };
+  const inferredHolidayRangeCache = {};
+
+  // ===================== 工具方法 =====================
+  function clone(data) {
+    return JSON.parse(JSON.stringify(data));
+  }
+  function createCode(prefix) {
+    return `${prefix}${dayjs().format('YYYYMMDD')}${String(
+      Math.floor(Math.random() * 1000000)
+    ).padStart(6, '0')}`;
+  }
+  function createHolidayRule(dateType = 3, calendarDate = '', remark = '') {
+    return {
+      id: Date.now() + Math.random(),
+      calendarDate,
+      dateType,
+      remark
+    };
+  }
+  function normalizeYearList(applyYear) {
+    if (Array.isArray(applyYear)) {
+      return applyYear.map(Number).filter(Boolean);
+    }
+    return String(applyYear || '')
+      .split(',')
+      .map((item) => Number(item.trim()))
+      .filter(Boolean);
+  }
+  function getFullMonthList() {
+    return Array.from({ length: 12 }, (_, index) => index + 1);
+  }
+
+  export default {
+    name: 'RuleRestPanel',
+    props: {
+      // 用于编辑模式回填,传 null/不传时为新建模式
+      row: {
+        type: Object,
+        default: null
+      },
+      // 用于同名/同年重复校验;不传则跳过重复校验
+      existingCalendars: {
+        type: Array,
+        default: () => []
+      }
+    },
+    data() {
+      return {
+        form: this.buildInitialForm(this.row),
+        rules: {
+          calendarName: [
+            { required: true, message: '请输入日历名称', trigger: 'blur' }
+          ],
+          calendarType: [
+            { required: true, message: '请选择日历类型', trigger: 'change' }
+          ],
+          applyYear: [
+            { required: true, message: '请选择适用年份', trigger: 'change' }
+          ],
+          applyMonth: [
+            { required: true, message: '请选择适用月份', trigger: 'change' }
+          ]
+        },
+        // 暴露给模板使用的常量
+        calendarTypeOptions,
+        yearOptions,
+        monthOptions,
+        restModeOptions,
+        restWeekdayOptions
+      };
+    },
+    computed: {
+      formApplyYear: {
+        get() {
+          return normalizeYearList(this.form.applyYear)[0] || '';
+        },
+        set(value) {
+          this.form.applyYear = value ? [Number(value)] : [];
+          this.form.legalHolidayWorkDates = [];
+        }
+      },
+      legalHolidayOptions() {
+        return this.getLegalHolidayOptions(
+          this.form.applyYear,
+          this.form.applyMonth
+        );
+      },
+      legalHolidayGroups() {
+        const groupMap = {};
+        this.legalHolidayOptions.forEach((item) => {
+          if (!item || !item.calendarDate) {
+            return;
+          }
+          const name = item.name || '法定节假日';
+          if (!groupMap[name]) {
+            groupMap[name] = { name, dates: [] };
+          }
+          groupMap[name].dates.push(item);
+        });
+        return Object.values(groupMap);
+      },
+      showLegalHolidaySection() {
+        return this.legalHolidayGroups.length > 0;
+      },
+      legalHolidayVisibleWorkCount() {
+        const visibleDates = new Set(
+          this.legalHolidayOptions.map((item) => item.calendarDate)
+        );
+        return (this.form.legalHolidayWorkDates || []).filter((date) =>
+          visibleDates.has(date)
+        ).length;
+      },
+      legalHolidaySourceText() {
+        return '使用本地节假日';
+      }
+    },
+    mounted() {
+      // 节假日数据已通过本地查找表提供,无需远程加载
+    },
+    methods: {
+      // ===================== 对外方法 =====================
+      /** 表单校验代理,供外部触发 */
+      validate(callback) {
+        return this.$refs.form?.validate(callback);
+      },
+      clearValidate() {
+        return this.$refs.form?.clearValidate?.();
+      },
+      /** 重置表单为指定行数据(或新建默认),供外部触发 */
+      resetTo(row) {
+        this.form = this.buildInitialForm(row);
+        this.$nextTick(() => {
+          this.$refs.form?.clearValidate?.();
+        });
+      },
+      // ===================== 表单相关 =====================
+      getDefaultForm() {
+        return {
+          id: '',
+          calendarCode: createCode('CLD'),
+          calendarName: '',
+          calendarType: 1,
+          applyYear: [currentYear],
+          applyMonth: getFullMonthList(),
+          status: 0,
+          shiftId: '',
+          shiftName: '',
+          shiftCode: '',
+          timeRanges: [],
+          restMode: 'double',
+          restWeekdays: [6, 0],
+          legalHolidayWorkDates: [],
+          holidayRules: [],
+          remark: ''
+        };
+      },
+      /**
+       * 合并默认表单与传入行:保证所有字段类型正确、避免脏数据导致运行时报错
+       */
+      buildInitialForm(row) {
+        const base = this.getDefaultForm();
+        if (!row || typeof row !== 'object') {
+          return base;
+        }
+        const merged = { ...base, ...clone(row) };
+        // 数值类型兜底
+        merged.calendarType = Number(merged.calendarType || 1);
+        merged.status = Number(merged.status || 0);
+        // 适用年份:统一为数组
+        merged.applyYear = normalizeYearList(merged.applyYear);
+        if (!merged.applyYear.length) {
+          merged.applyYear = [currentYear];
+        }
+        // 适用月份:统一为数字数组
+        merged.applyMonth = (merged.applyMonth || [])
+          .map(Number)
+          .filter(Boolean);
+        if (!merged.applyMonth.length) {
+          merged.applyMonth = getFullMonthList();
+        }
+        // 数组字段兜底
+        merged.timeRanges = Array.isArray(merged.timeRanges)
+          ? merged.timeRanges.filter((item) => item && typeof item === 'object')
+          : [];
+        merged.restWeekdays = Array.isArray(merged.restWeekdays)
+          ? merged.restWeekdays.map(Number)
+          : [];
+        merged.legalHolidayWorkDates = Array.isArray(merged.legalHolidayWorkDates)
+          ? merged.legalHolidayWorkDates.filter(Boolean)
+          : [];
+        merged.holidayRules = Array.isArray(merged.holidayRules)
+          ? merged.holidayRules.filter((item) => item && typeof item === 'object')
+          : [];
+        // 字符串字段兜底
+        merged.calendarName = String(merged.calendarName || '');
+        merged.calendarCode = String(merged.calendarCode || base.calendarCode);
+        merged.restMode = merged.restMode || 'double';
+        merged.remark = String(merged.remark || '');
+        merged.shiftId = String(merged.shiftId || '');
+        merged.shiftName = String(merged.shiftName || '');
+        merged.shiftCode = String(merged.shiftCode || '');
+        // 重新分配 id,确保 v-for key 不冲突
+        if (merged.id) {
+          merged.holidayRules = merged.holidayRules.map((r) => ({
+            ...r,
+            id: r.id || Date.now() + Math.random()
+          }));
+        }
+        return merged;
+      },
+      handleSave() {
+        this.$refs.form.validate(async (valid) => {
+          if (!valid) {
+            this.$emit('validate-fail');
+            return;
+          }
+          if (this.hasInvalidRestRule()) {
+            this.$emit('validate-fail');
+            return;
+          }
+          if (this.hasInvalidHolidayRules()) {
+            this.$emit('validate-fail');
+            return;
+          }
+          if (this.hasDuplicateCalendarTemplate()) {
+            this.$message.error('同类型同年份日历已存在,请勿重复创建');
+            this.$emit('validate-fail');
+            return;
+          }
+          try {
+            const payload = this.toPayload(this.form);
+            const savedForm = clone(this.form);
+            if (this.form.id) {
+              await editCalendar(payload);
+            } else {
+              const result = await addCalendar(payload);
+              if (result?.id) {
+                savedForm.id = result.id;
+                this.form.id = result.id;
+                await updateCalendarStatus({
+                  id: result.id,
+                  status: Number(this.form.status || 0)
+                });
+              }
+              if (result?.calendarCode) {
+                savedForm.calendarCode = result.calendarCode;
+                this.form.calendarCode = result.calendarCode;
+              }
+            }
+            this.$message.success('保存成功');
+            this.$emit('saved', savedForm);
+          } catch (e) {
+            this.$message.error(e?.message || '保存失败,请检查接口返回');
+            this.$emit('save-fail', e);
+          }
+        });
+      },
+      hasInvalidRestRule() {
+        if (
+          this.form.restMode === 'custom' &&
+          !(this.form.restWeekdays || []).length
+        ) {
+          this.$message.error('自定义休息请至少选择一个休息星期');
+          return true;
+        }
+        return false;
+      },
+      hasInvalidHolidayRules() {
+        const dateMap = {};
+        (this.form.legalHolidayWorkDates || []).forEach((date) => {
+          dateMap[date] = true;
+        });
+        const years = new Set(
+          normalizeYearList(this.form.applyYear).map(String)
+        );
+        const months = new Set(
+          (this.form.applyMonth || []).map((item) => Number(item))
+        );
+        for (const rule of this.form.holidayRules || []) {
+          if (!rule.calendarDate) {
+            this.$message.error('特殊日期存在未选择日期的配置,请补充或删除');
+            return true;
+          }
+          const ruleDate = dayjs(rule.calendarDate);
+          if (!ruleDate.isValid()) {
+            this.$message.error('特殊日期存在非法日期,请调整后保存');
+            return true;
+          }
+          if (!years.has(ruleDate.format('YYYY'))) {
+            this.$message.error('特殊日期必须在适用年份范围内');
+            return true;
+          }
+          if (!months.has(ruleDate.month() + 1)) {
+            this.$message.error('特殊日期必须在适用月份范围内');
+            return true;
+          }
+          if (dateMap[rule.calendarDate]) {
+            this.$message.error('特殊日期存在重复日期,请调整后保存');
+            return true;
+          }
+          dateMap[rule.calendarDate] = true;
+        }
+        return false;
+      },
+      hasDuplicateCalendarTemplate() {
+        if (!this.existingCalendars || !this.existingCalendars.length) {
+          return false;
+        }
+        return this.existingCalendars.some((item) => {
+          if (item.id === this.form.id) {
+            return false;
+          }
+          const sameType = item.calendarType === this.form.calendarType;
+          // applyYear 在 toPayload 中会被序列化成字符串,列表回填时统一转回数组
+          const itemYears = normalizeYearList(item.applyYear);
+          const currentYears = normalizeYearList(this.form.applyYear);
+          const sameYear =
+            itemYears.length > 0 &&
+            currentYears.length > 0 &&
+            itemYears.some((year) => currentYears.includes(year));
+          return (
+            sameType &&
+            sameYear &&
+            item.calendarName === this.form.calendarName
+          );
+        });
+      },
+      toPayload(form) {
+        const rulePayload = this.getCalendarRulePayload(form);
+        return {
+          id: form.id || undefined,
+          calendarName: form.calendarName,
+          calendarType: form.calendarType,
+          status: Number(form.status || 0),
+          applyYear: Array.isArray(form.applyYear)
+            ? form.applyYear.join(',')
+            : form.applyYear,
+          remark: this.stripCalendarRuleConfig(form.remark),
+          ...rulePayload,
+          segmentList: this.buildCalendarSegments(form)
+        };
+      },
+      getCalendarRulePayload(form) {
+        return {
+          applyMonth: form.applyMonth || getFullMonthList(),
+          shiftId: form.shiftId || '',
+          shiftName: form.shiftName || '',
+          shiftCode: form.shiftCode || '',
+          restMode: form.restMode || 'double',
+          restWeekdays: form.restWeekdays || [],
+          legalHolidayWorkDates: form.legalHolidayWorkDates || [],
+          holidayRules: (form.holidayRules || [])
+            .filter((item) => item.calendarDate)
+            .map((item) => ({
+              calendarDate: item.calendarDate,
+              dateType: Number(item.dateType || 3),
+              remark: item.remark || ''
+            }))
+        };
+      },
+      stripCalendarRuleConfig(remark) {
+        return String(remark || '')
+          .replace(/\s*\[calendarRules\][\s\S]*?\[\/calendarRules\]\s*/g, '')
+          .trim();
+      },
+      buildCalendarSegments(form) {
+        // 过滤掉 undefined/空对象,避免后续访问 startTime/endTime 报错
+        const ranges = (form.timeRanges || []).filter(
+          (item) => item && typeof item === 'object'
+        );
+        const monthList = (form.applyMonth || getFullMonthList())
+          .map(Number)
+          .filter(Boolean);
+        const applyMonths = new Set(monthList);
+        const restMode =
+          form.restMode || this.getRestModeByWeekdays(form.restWeekdays);
+        const customRestWeekdays = new Set(
+          (form.restWeekdays || []).map(Number)
+        );
+        const specialRuleMap = {};
+        const specialRules = [
+          ...this.getLegalHolidayRules(form),
+          ...(form.holidayRules || [])
+        ];
+        specialRules.forEach((rule) => {
+          if (rule.calendarDate) {
+            specialRuleMap[rule.calendarDate] = {
+              ...rule,
+              dateType: Number(rule.dateType || 3)
+            };
+          }
+        });
+        const segments = [];
+        if (!applyMonths.size) {
+          return segments;
+        }
+        normalizeYearList(form.applyYear).forEach((year) => {
+          const months = Array.from(applyMonths).sort((a, b) => a - b);
+          let cursor = dayjs(
+            `${year}-${String(months[0]).padStart(2, '0')}-01`
+          );
+          const lastMonth = months[months.length - 1];
+          const end = dayjs(
+            `${year}-${String(lastMonth).padStart(2, '0')}-01`
+          ).endOf('month');
+          while (!cursor.isAfter(end, 'day')) {
+            if (!applyMonths.has(cursor.month() + 1)) {
+              cursor = cursor.add(1, 'day');
+              continue;
+            }
+            const calendarDate = cursor.format('YYYY-MM-DD');
+            const specialRule = specialRuleMap[calendarDate];
+            const legalHolidayName = this.getLegalHolidayName(cursor);
+            const dateType = this.getCalendarDateType(
+              cursor,
+              restMode,
+              customRestWeekdays,
+              specialRule
+            );
+            if (dateType === 1) {
+              const workRanges = ranges.length
+                ? ranges
+                : [
+                    {
+                      startTime: '00:00',
+                      endTime: '23:59',
+                      scheduleStatus: 0,
+                      remark:
+                        specialRule?.remark || legalHolidayName || '工作日'
+                    }
+                  ];
+              workRanges.forEach((range) => {
+                // 兼容历史脏数据:跳过空条目,并对缺字段的 range 进行兜底
+                if (!range) {
+                  return;
+                }
+                segments.push({
+                  calendarDate,
+                  startTime: range.startTime || '00:00',
+                  endTime: range.endTime || '23:59',
+                  dateType: 1,
+                  scheduleStatus:
+                    range.scheduleStatus === undefined
+                      ? 0
+                      : range.scheduleStatus,
+                  remark:
+                    range.name ||
+                    range.remark ||
+                    specialRule?.remark ||
+                    legalHolidayName ||
+                    ''
+                });
+              });
+            } else {
+              segments.push({
+                calendarDate,
+                startTime: '00:00',
+                endTime: '23:59',
+                dateType,
+                scheduleStatus: 0,
+                remark:
+                  specialRule?.remark ||
+                  legalHolidayName ||
+                  (dateType === 3 ? '法定节假日' : '休息日')
+              });
+            }
+            cursor = cursor.add(1, 'day');
+          }
+        });
+        return segments;
+      },
+      getLegalHolidayRules(form) {
+        const holidayInfoMap = {};
+        this.getLegalHolidayOptions(form.applyYear, form.applyMonth).forEach(
+          (item) => {
+            holidayInfoMap[item.calendarDate] = item;
+          }
+        );
+        return (form.legalHolidayWorkDates || [])
+          .filter((date) => holidayInfoMap[date])
+          .map((date) => ({
+            calendarDate: date,
+            dateType: 1,
+            remark: holidayInfoMap[date].name
+          }));
+      },
+      getCalendarDateType(cursor, restMode, customRestWeekdays, specialRule) {
+        if (specialRule && specialRule.dateType !== undefined) {
+          return Number(specialRule.dateType) || 3;
+        }
+        if (this.getLegalHolidayName(cursor)) {
+          return 3;
+        }
+        if (restMode === 'custom') {
+          return customRestWeekdays.has(cursor.day()) ? 2 : 1;
+        }
+        return this.isRestDateByMode(cursor, restMode) ? 2 : 1;
+      },
+      getRestModeByWeekdays(weekdays) {
+        const values = (weekdays || []).map(Number).sort().join(',');
+        if (values === '0') {
+          return 'single';
+        }
+        if (values === '0,6') {
+          return 'double';
+        }
+        if (!values) {
+          return 'none';
+        }
+        return 'double';
+      },
+      isRestDateByMode(dateItem, restMode) {
+        const weekDay = dateItem.day();
+        if (restMode === 'none') {
+          return false;
+        }
+        if (restMode === 'single') {
+          return weekDay === 0;
+        }
+        if (restMode === 'alternate') {
+          const yearStart = dayjs(`${dateItem.year()}-01-01`);
+          const weekIndex = Math.floor(dateItem.diff(yearStart, 'day') / 7);
+          return weekDay === 0 || (weekDay === 6 && weekIndex % 2 === 0);
+        }
+        if (restMode === 'custom') {
+          return false;
+        }
+        return weekDay === 0 || weekDay === 6;
+      },
+      addHolidayRule() {
+        this.form.holidayRules.push(createHolidayRule(1));
+      },
+      removeHolidayRule(index) {
+        this.form.holidayRules.splice(index, 1);
+      },
+      handleApplyMonthChange() {
+        this.pruneLegalHolidayWorkDates();
+      },
+      pruneLegalHolidayWorkDates() {
+        const visibleDates = new Set(
+          this.legalHolidayOptions.map((item) => item.calendarDate)
+        );
+        this.form.legalHolidayWorkDates = (
+          this.form.legalHolidayWorkDates || []
+        ).filter((date) => visibleDates.has(date));
+      },
+      changeLegalHolidayWorkStatus(calendarDate, checked) {
+        const dates = new Set(this.form.legalHolidayWorkDates || []);
+        if (checked) {
+          dates.add(calendarDate);
+        } else {
+          dates.delete(calendarDate);
+        }
+        this.form.legalHolidayWorkDates = Array.from(dates).sort();
+      },
+      toggleLegalHolidayWorkStatus(calendarDate) {
+        const checked = !(
+          this.form.legalHolidayWorkDates || []
+        ).includes(calendarDate);
+        this.changeLegalHolidayWorkStatus(calendarDate, checked);
+      },
+      formatHolidayDate(calendarDate) {
+        return dayjs(calendarDate).format('MM-DD');
+      },
+      getLegalHolidayInfo(dateItem) {
+        if (!dateItem || !dateItem.isValid()) {
+          return null;
+        }
+        const holidayInfo = this.getConfiguredHolidayRanges(
+          dateItem.year()
+        ).find((item) => {
+          if (!item || !item.start || !item.end) {
+            return false;
+          }
+          const start = dayjs(item.start);
+          const end = dayjs(item.end);
+          if (!start.isValid() || !end.isValid()) {
+            return false;
+          }
+          return (
+            !dateItem.isBefore(start, 'day') && !dateItem.isAfter(end, 'day')
+          );
+        });
+        if (holidayInfo) {
+          return {
+            name: holidayInfo.name,
+            calendarDate: dateItem.format('YYYY-MM-DD')
+          };
+        }
+        // 不使用第三方依赖:通过内置查找表判断是否为法定节假日
+        const holidayName = getChineseHolidayName(dateItem);
+        const match = holidayName
+          ? legalHolidayKeywords.find((item) => item.label === holidayName)
+          : null;
+        return match
+          ? {
+              name: match.label,
+              calendarDate: dateItem.format('YYYY-MM-DD')
+            }
+          : null;
+      },
+      getLegalHolidayName(dateItem) {
+        return this.getLegalHolidayInfo(dateItem)?.name || '';
+      },
+      getLegalHolidayOptions(applyYear, applyMonth) {
+        const monthSet = new Set(
+          (applyMonth || getFullMonthList()).map(Number)
+        );
+        const holidayMap = {};
+        normalizeYearList(applyYear).forEach((year) => {
+          const ranges = this.getConfiguredHolidayRanges(year);
+          ranges.forEach((range) => {
+            // 跳过缺字段或解析失败的区间,避免死循环或写出脏数据
+            if (!range || !range.start || !range.end) {
+              return;
+            }
+            let cursor = dayjs(range.start);
+            const end = dayjs(range.end);
+            if (!cursor.isValid() || !end.isValid()) {
+              return;
+            }
+            while (!cursor.isAfter(end, 'day')) {
+              if (monthSet.has(cursor.month() + 1)) {
+                const calendarDate = cursor.format('YYYY-MM-DD');
+                if (!holidayMap[calendarDate]) {
+                  holidayMap[calendarDate] = {
+                    name: range.name || '法定节假日',
+                    calendarDate
+                  };
+                }
+              }
+              cursor = cursor.add(1, 'day');
+            }
+          });
+        });
+        return Object.values(holidayMap).sort((a, b) =>
+          a.calendarDate.localeCompare(b.calendarDate)
+        );
+      },
+      getConfiguredHolidayRanges(year) {
+        // 节假日数据全部使用本地查找表(已覆盖 2020-2030 全年)
+        return (
+          officialHolidayRanges[year] || this.getInferredHolidayRanges(year)
+        );
+      },
+      getInferredHolidayRanges(year) {
+        if (inferredHolidayRangeCache[year]) {
+          return inferredHolidayRangeCache[year];
+        }
+        const festivalDates = this.getLegalHolidayFestivalDates(year);
+        const ranges = Object.keys(festivalDates).map((name) => {
+          const rule = inferredHolidayRules[name] || { before: 0, after: 0 };
+          const date = dayjs(festivalDates[name]);
+          return {
+            name,
+            start: date.subtract(rule.before, 'day').format('YYYY-MM-DD'),
+            end: date.add(rule.after, 'day').format('YYYY-MM-DD')
+          };
+        });
+        inferredHolidayRangeCache[year] = ranges;
+        return ranges;
+      },
+      getLegalHolidayFestivalDates(year) {
+        const festivalMap = {};
+        const firstDay = dayjs(`${year}-01-01`);
+        const end = firstDay.endOf('year');
+        let cursor = firstDay;
+        while (!cursor.isAfter(end, 'day')) {
+          // 不使用第三方依赖:通过内置查找表判断是否为法定节假日
+          const holidayName = getChineseHolidayName(cursor);
+          const match = holidayName
+            ? legalHolidayKeywords.find((item) => item.label === holidayName)
+            : null;
+          if (match && !festivalMap[match.label]) {
+            festivalMap[match.label] = cursor.format('YYYY-MM-DD');
+          }
+          cursor = cursor.add(1, 'day');
+        }
+        return festivalMap;
+      },
+      mergeHolidayDatesToRanges(holidayList) {
+        const list = holidayList
+          .filter((item) => item && item.calendarDate)
+          .sort((a, b) => a.calendarDate.localeCompare(b.calendarDate));
+        const ranges = [];
+        list.forEach((item) => {
+          const last = ranges[ranges.length - 1];
+          const lastEnd = last ? dayjs(last.end) : null;
+          const canMerge =
+            last &&
+            last.name === item.name &&
+            lastEnd &&
+            lastEnd.isValid() &&
+            dayjs(item.calendarDate).diff(lastEnd, 'day') === 1;
+          if (canMerge) {
+            last.end = item.calendarDate;
+          } else {
+            ranges.push({
+              name: item.name || '法定节假日',
+              start: item.calendarDate,
+              end: item.calendarDate
+            });
+          }
+        });
+        return ranges;
+      }
+    }
+  };
+</script>
+
+<style scoped>
+  .rule-rest-panel {
+    padding: 4px 0;
+  }
+  .rule-rest-panel .rest-rule-editor {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+  .rule-rest-panel .custom-rest-weekdays {
+    margin-top: 4px;
+  }
+  .rule-rest-panel .rule-tip {
+    color: #909399;
+    font-size: 12px;
+    line-height: 1.6;
+  }
+  .rule-rest-panel .holiday-rule-editor {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+  .rule-rest-panel .holiday-rule-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+  }
+  .rule-rest-panel .danger-link {
+    color: #f56c6c;
+  }
+  .rule-rest-panel .legal-holiday-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    max-height: 520px;
+    overflow-y: auto;
+    overflow-x: hidden;
+    padding-right: 4px;
+  }
+  .rule-rest-panel .legal-holiday-list::-webkit-scrollbar {
+    width: 6px;
+  }
+  .rule-rest-panel .legal-holiday-list::-webkit-scrollbar-thumb {
+    background: #c0c4cc;
+    border-radius: 3px;
+  }
+  .rule-rest-panel .legal-holiday-list::-webkit-scrollbar-track {
+    background: transparent;
+  }
+  .rule-rest-panel .legal-holiday-group {
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    overflow: hidden;
+    flex-shrink: 0;
+  }
+  .rule-rest-panel .legal-holiday-group-head {
+    display: flex;
+    justify-content: space-between;
+    padding: 6px 12px;
+    background: #f5f7fa;
+    color: #303133;
+    font-size: 13px;
+  }
+  .rule-rest-panel .legal-holiday-dates {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    padding: 8px 12px;
+    max-height: 200px;
+    overflow-y: auto;
+  }
+  .rule-rest-panel .legal-holiday-dates::-webkit-scrollbar {
+    width: 4px;
+  }
+  .rule-rest-panel .legal-holiday-dates::-webkit-scrollbar-thumb {
+    background: #dcdfe6;
+    border-radius: 2px;
+  }
+  .rule-rest-panel .legal-holiday-date {
+    display: inline-flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    min-width: 56px;
+    padding: 4px 8px;
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+    background: #fff;
+    cursor: pointer;
+    color: #606266;
+    font-size: 12px;
+    line-height: 1.4;
+    transition: all 0.15s;
+    flex-shrink: 0;
+  }
+  .rule-rest-panel .legal-holiday-date em {
+    font-style: normal;
+    color: #909399;
+    font-size: 11px;
+  }
+  .rule-rest-panel .legal-holiday-date.is-work {
+    background: #ecf5ff;
+    border-color: #409eff;
+    color: #409eff;
+  }
+  .rule-rest-panel .legal-holiday-date.is-work em {
+    color: #409eff;
+  }
+  .rule-rest-panel .form-section header .form-hint {
+    color: #909399;
+    font-size: 12px;
+    align-self: center;
+  }
+  .rule-rest-panel section.form-section header {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 12px;
+  }
+  .rule-rest-panel section.form-section header h3 + p {
+    flex-basis: 100%;
+    margin: 0;
+  }
+</style>

+ 151 - 0
src/views/attendance/components/RuleWorkTimePanel.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="tab-panel">
+    <el-form :model="form" label-position="top">
+      <section class="form-section">
+        <header>
+          <h3>上下班时间</h3>
+          <p>选择需要打卡的工作日</p>
+        </header>
+        <div class="week-chip-wrap">
+          <el-checkbox-group v-model="form.workDays" class="week-chips">
+            <el-checkbox-button v-for="d in weekOptions" :key="d" :label="d">{{ d }}</el-checkbox-button>
+          </el-checkbox-group>
+        </div>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>工作时间</h3>
+          <p>可添加多个工作时间段,每个时间段可设置午休</p>
+        </header>
+        <div v-for="(shift, si) in form.workShifts" :key="shift.key" class="shift-card">
+          <header class="shift-head">
+            <span class="shift-no"><i>{{ si + 1 }}</i></span>
+            <div class="shift-head-actions">
+              <el-button v-if="form.workShifts.length > 1" type="text" size="mini" class="is-danger" @click="removeWorkShift(si)">删除该时段</el-button>
+            </div>
+          </header>
+
+          <div class="form-grid">
+            <el-form-item label="上班时间">
+              <div class="field-with-dropdown">
+                <el-time-picker v-model="shift.start" placeholder="上班时间" value-format="HH:mm" format="HH:mm" class="time-pick" />
+                <el-select v-model="shift.startOpen" class="open-select">
+                  <el-option label="需要打卡" value="是" />
+                  <el-option label="无需打卡" value="否" />
+                </el-select>
+              </div>
+            </el-form-item>
+            <el-form-item label="下班时间">
+              <div class="field-with-dropdown">
+                <el-time-picker v-model="shift.end" placeholder="下班时间" value-format="HH:mm" format="HH:mm" class="time-pick" />
+                <el-select v-model="shift.endOpen" class="open-select">
+                  <el-option label="需要打卡" value="是" />
+                  <el-option label="无需打卡" value="否" />
+                </el-select>
+              </div>
+            </el-form-item>
+          </div>
+
+          <div class="lunch-block">
+            <div class="lunch-title">午休时间</div>
+            <div class="lunch-row">
+              <el-time-picker v-model="shift.lunchBreaks[0].start" placeholder="开始" value-format="HH:mm" format="HH:mm" style="width:140px" />
+              <span class="dash">至</span>
+              <el-time-picker v-model="shift.lunchBreaks[0].end" placeholder="结束" value-format="HH:mm" format="HH:mm" style="width:140px" />
+            </div>
+          </div>
+        </div>
+
+        <el-button plain icon="el-icon-plus" class="add-shift-btn" native-type="button" @click="addWorkShift">添加工作时间段</el-button>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>关联打开对象</h3>
+          <p>选择与本规则关联的打卡设备 / 应用</p>
+        </header>
+        <div class="form-grid">
+          <el-form-item label="关联打开对象">
+            <el-select v-model="form.relatedTarget" placeholder="选择关联对象" style="width:100%">
+              <el-option v-for="t in relatedTargetOptions" :key="t.value" :label="t.label" :value="t.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="可打卡时间段">
+            <div class="time-pair">
+              <el-select v-model="form.punchStart" placeholder="开始时间" style="width:120px">
+                <el-option v-for="t in timeOptions" :key="t" :label="t" :value="t" />
+              </el-select>
+              <span class="dash">至</span>
+              <el-select v-model="form.punchEnd" placeholder="结束时间" style="width:120px">
+                <el-option v-for="t in timeOptions" :key="t" :label="t" :value="t" />
+              </el-select>
+            </div>
+          </el-form-item>
+        </div>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>弹性打卡</h3>
+          <p>为员工提供弹性上下班时间</p>
+        </header>
+        <el-form-item label="是否允许弹性打卡">
+          <el-switch v-model="form.flexEnabled" active-text="开启" inactive-text="关闭" />
+        </el-form-item>
+        <el-form-item v-if="form.flexEnabled" label="下班晚走,次日可晚到">
+          <el-switch v-model="form.lateComp" active-text="开启" inactive-text="关闭" />
+          <span class="field-hint">开启后,当日下班晚走,次日上班可最晚延后相同时长</span>
+        </el-form-item>
+      </section>
+
+      <section class="form-section">
+        <header>
+          <h3>半天工作时间</h3>
+          <p>设置半天工作的时间范围</p>
+        </header>
+        <el-form-item label="半天工作时间">
+          <div class="time-pair">
+            <el-select v-model="form.halfStart" placeholder="开始时间" style="width:120px">
+              <el-option v-for="t in timeOptions" :key="t" :label="t" :value="t" />
+            </el-select>
+            <span class="dash">至</span>
+            <el-select v-model="form.halfEnd" placeholder="结束时间" style="width:120px">
+              <el-option v-for="t in timeOptions" :key="t" :label="t" :value="t" />
+            </el-select>
+          </div>
+        </el-form-item>
+      </section>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RuleWorkTimePanel',
+  props: {
+    form: { type: Object, required: true },
+    weekOptions: { type: Array, default: () => [] },
+    timeOptions: { type: Array, default: () => [] },
+    relatedTargetOptions: { type: Array, default: () => [] },
+  },
+  methods: {
+    addWorkShift() {
+      this.form.workShifts.push({
+        key: 'WS' + Date.now(),
+        start: '09:00',
+        end: '18:00',
+        startOpen: '是',
+        endOpen: '是',
+        lunchBreaks: [{ key: 'L1', start: '12:00', end: '13:00' }]
+      });
+    },
+    removeWorkShift(index) {
+      if (this.form.workShifts.length <= 1) {
+        return this.$message.warning('至少保留一个工作时间段');
+      }
+      this.form.workShifts.splice(index, 1);
+    },
+  },
+};
+</script>

+ 212 - 0
src/views/attendance/mock.js

@@ -0,0 +1,212 @@
+export const RULE_TYPES = [
+  { value: 'fixed', label: '固定时间' },
+  { value: 'flexible', label: '弹性时间' },
+  { value: 'comprehensive', label: '综合工时' }
+];
+
+export const PUNCH_METHOD_OPTIONS = [
+  { value: 'gps', label: 'GPS 定位' },
+  { value: 'wifi', label: 'WiFi' },
+  { value: 'beacon', label: '蓝牙' },
+  { value: 'face', label: '人脸识别' },
+  { value: 'ding', label: '钉钉' },
+  { value: 'outside', label: '外勤' }
+];
+
+export const ORG_OPTIONS = [
+  { value: 'HQ', label: '瀚宁智造集团' },
+  { value: 'HD', label: '瀚宁智造(华东)' },
+  { value: 'HN', label: '瀚宁智造(华南)' }
+];
+
+export const PUNCH_RULES = [
+  {
+    id: 'PR001', name: '总部行政班', type: 'fixed',
+    workTime: '09:00 - 18:00',
+    holiday: '跟随国家法定节假日',
+    restDays: ['周六', '周日'],
+    methods: ['gps', 'face'],
+    location: '总部园区(半径 500m)',
+    wifi: 'AIMILL-OFFICE',
+    devices: 'AIMILL-FACE-001 ~ 008',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,平日 1.5x / 周末 2x / 节假日 3x',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-26 10:23'
+  },
+  {
+    id: 'PR002', name: '生产车间两班倒', type: 'fixed',
+    workTime: '08:00 - 20:00',
+    holiday: '跟随国家法定节假日 + 春节调休',
+    restDays: ['轮班休息'],
+    methods: ['face', 'beacon'],
+    location: '生产一部车间(半径 100m)',
+    wifi: 'AIMILL-WORKSHOP',
+    devices: 'AIMILL-FACE-101 ~ 124',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,平日 1.5x / 周末 2x / 节假日 3x + 夜班补贴',
+    makeup: '允许(限定 2 次 / 月)',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-25 17:41'
+  },
+  {
+    id: 'PR003', name: '仓储物流晚班', type: 'fixed',
+    workTime: '17:00 - 01:00(跨日)',
+    holiday: '跟随国家法定节假日',
+    restDays: ['轮班休息'],
+    methods: ['face', 'beacon'],
+    location: '仓储物流部(半径 50m)',
+    wifi: 'AIMILL-LOGISTICS',
+    devices: 'AIMILL-FACE-201 ~ 212',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,含夜班补贴 22:00 后 30 元/h',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-21 11:08'
+  },
+  {
+    id: 'PR004', name: '夜班连续生产', type: 'fixed',
+    workTime: '20:00 - 08:00(跨日)',
+    holiday: '跟随国家法定节假日',
+    restDays: ['轮班休息'],
+    methods: ['face'],
+    location: '生产二部车间',
+    wifi: 'AIMILL-NIGHT',
+    devices: 'AIMILL-FACE-301 ~ 324',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,平日 1.5x + 夜班补贴',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-18 14:21'
+  },
+  {
+    id: 'PR005', name: '研发中心弹性', type: 'flexible',
+    workTime: '09:00 - 17:00(弹性 07:00-10:00 到岗)',
+    holiday: '跟随国家法定节假日 + 企业自定义调休',
+    restDays: ['周六', '周日'],
+    methods: ['wifi', 'ding'],
+    location: '研发中心办公区',
+    wifi: 'AIMILL-RD',
+    devices: '钉钉 App',
+    overtime: '关闭',
+    overtimeDuration: '弹性班次不计加班',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-15 09:33'
+  },
+  {
+    id: 'PR006', name: '炼钢车间三班倒', type: 'fixed',
+    workTime: '00:00 - 08:00 / 08:00 - 16:00 / 16:00 - 24:00',
+    holiday: '跟随国家法定节假日',
+    restDays: ['轮班休息'],
+    methods: ['face', 'beacon'],
+    location: '炼钢车间(半径 80m)',
+    wifi: 'AIMILL-STEEL',
+    devices: 'AIMILL-FACE-401 ~ 412',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,含高危岗位补贴',
+    makeup: '允许(限定 3 次 / 月)',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-12 16:08'
+  },
+  {
+    id: 'PR007', name: '销售外勤岗', type: 'flexible',
+    workTime: '09:00 - 18:00(外勤不强制打卡)',
+    holiday: '跟随国家法定节假日',
+    restDays: ['周六', '周日'],
+    methods: ['gps', 'ding'],
+    location: '客户拜访现场(现场照片 + GPS)',
+    wifi: '客户现场公共 WiFi',
+    devices: '钉钉 App',
+    overtime: '开启',
+    overtimeDuration: '≥ 60 分钟起算,平日 1.5x / 周末 2x',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-08-09 11:42'
+  },
+  {
+    id: 'PR008', name: '华东分公司', type: 'fixed',
+    workTime: '08:30 - 17:30',
+    holiday: '跟随国家法定节假日 + 当地调休',
+    restDays: ['周六', '周日'],
+    methods: ['gps', 'ding'],
+    location: '华东分公司办公区(半径 500m)',
+    wifi: 'AIMILL-HD',
+    devices: '钉钉 App',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,平日 1.5x / 周末 2x',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HD',
+    enabled: true,
+    updatedAt: '2026-08-05 14:55'
+  },
+  {
+    id: 'PR009', name: '华南分公司', type: 'fixed',
+    workTime: '08:30 - 17:30',
+    holiday: '跟随国家法定节假日 + 当地调休',
+    restDays: ['周六', '周日'],
+    methods: ['gps', 'ding'],
+    location: '华南分公司办公区(半径 500m)',
+    wifi: 'AIMILL-HN',
+    devices: '钉钉 App',
+    overtime: '开启',
+    overtimeDuration: '≥ 30 分钟起算,平日 1.5x / 周末 2x',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HN',
+    enabled: false,
+    updatedAt: '2026-07-28 10:18'
+  },
+  {
+    id: 'PR010', name: '高管综合工时', type: 'comprehensive',
+    workTime: '月 / 季 / 年总工时控制',
+    holiday: '跟随国家法定节假日',
+    restDays: ['按需'],
+    methods: ['ding'],
+    location: '不限',
+    wifi: '不限',
+    devices: '钉钉 App',
+    overtime: '关闭',
+    overtimeDuration: '超出部分按调休或加班补偿',
+    makeup: '允许',
+    leaveMakeup: '请假当日无需补卡',
+    scope: 'HQ',
+    enabled: true,
+    updatedAt: '2026-07-22 15:30'
+  }
+];
+
+export function ruleTypeLabel(value) {
+  return ({ fixed: '固定时间', flexible: '弹性时间', comprehensive: '综合工时' })[value] || value;
+}
+
+export function methodLabel(value) {
+  return ({ gps: 'GPS 定位', wifi: 'WiFi', beacon: '蓝牙', ding: '钉钉', face: '人脸识别', outside: '外勤' })[value] || value;
+}
+
+export function methodTone(value) {
+  return ({
+    gps: 'tag-blue',
+    wifi: 'tag-purple',
+    beacon: 'tag-cyan',
+    ding: 'tag-green',
+    face: 'tag-amber',
+    outside: 'tag-red'
+  })[value] || 'tag-gray';
+}

+ 608 - 0
src/views/attendance/report/index.vue

@@ -0,0 +1,608 @@
+<template>
+  <main class="attendance-page stats-page" v-loading="loading">
+    <!-- 顶部筛选条 -->
+    <section class="workspace-card stats-header-card">
+      <div class="stats-header">
+        <div class="stats-header-title">
+          <h1>考勤统计</h1>
+          <small>PC 端考勤数据汇总分析</small>
+        </div>
+        <div class="stats-header-actions">
+          <el-select v-model="filter.month" size="medium" placeholder="时间范围">
+            <el-option v-for="m in monthOptions" :key="m" :label="m" :value="m" />
+          </el-select>
+          <el-select v-model="filter.department" size="medium" placeholder="部门">
+            <el-option v-for="d in departmentOptions" :key="d" :label="d" :value="d" />
+          </el-select>
+          <el-button type="primary" icon="el-icon-download" size="medium" @click="exportData">导出数据</el-button>
+        </div>
+      </div>
+    </section>
+
+    <!-- 指标卡片 -->
+    <section class="metric-grid stats-metric">
+      <div class="metric-card tone-blue">
+        <div class="metric-icon"><i class="el-icon-document-checked"></i></div>
+        <div class="metric-copy">
+          <small>正常人数</small>
+          <strong>10<em>人</em></strong>
+          <p>正常人数</p>
+        </div>
+        <i class="el-icon-document metric-tail"></i>
+      </div>
+      <div class="metric-card tone-red">
+        <div class="metric-icon"><i class="el-icon-warning-outline"></i></div>
+        <div class="metric-copy">
+          <small>异常人数</small>
+          <strong>13<em>人</em></strong>
+          <p>异常人数</p>
+        </div>
+        <i class="el-icon-sugar metric-tail"></i>
+      </div>
+      <div class="metric-card tone-green">
+        <div class="metric-icon"><i class="el-icon-data-line"></i></div>
+        <div class="metric-copy">
+          <small>出勤率</small>
+          <strong>88.5<em>%</em></strong>
+          <p>88.5%</p>
+        </div>
+        <i class="el-icon-trophy metric-tail"></i>
+      </div>
+      <div class="metric-card tone-amber">
+        <div class="metric-icon"><i class="el-icon-alarm-clock"></i></div>
+        <div class="metric-copy">
+          <small>迟到次数</small>
+          <strong>2<em>次</em></strong>
+          <p>迟到次数</p>
+        </div>
+        <i class="el-icon-user metric-tail"></i>
+      </div>
+    </section>
+
+    <!-- 图表区 -->
+    <section class="stats-charts">
+      <article class="workspace-card chart-panel chart-panel--punch">
+        <header class="card-head">
+          <div>
+            <h2>上下班打卡分析</h2>
+          </div>
+          <a class="card-link">本月每日打卡趋势<i class="el-icon-arrow-right"></i></a>
+        </header>
+        <div class="punch-body">
+          <div ref="punchDonut" class="punch-donut"></div>
+          <div ref="punchLine" class="punch-line"></div>
+        </div>
+      </article>
+      <article class="workspace-card chart-panel chart-panel--exception">
+        <header class="card-head">
+          <div>
+            <h2>异常类型统计</h2>
+          </div>
+          <a class="card-link">其他异常 2<i class="el-icon-arrow-right"></i></a>
+        </header>
+        <div ref="exceptionBar" class="exception-bar"></div>
+      </article>
+    </section>
+
+    <!-- 明细表 -->
+    <section class="workspace-card stats-table-card">
+      <header class="card-head">
+        <div>
+          <h2>考勤明细</h2>
+        </div>
+        <el-button type="primary" size="small" icon="el-icon-download" @click="exportTable">导出数据</el-button>
+      </header>
+      <div class="stats-table-wrap">
+        <table class="stats-table">
+          <thead>
+            <tr>
+              <th class="col-name">员工姓名</th>
+              <th>部门</th>
+              <th>岗位</th>
+              <th>出勤天数</th>
+              <th>迟到</th>
+              <th>矿工</th>
+              <th>状态</th>
+              <th class="col-action">操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="row in detailRows" :key="row.id">
+              <td class="col-name">
+                <div class="employee-cell">
+                  <span>{{ row.name.charAt(0) }}</span>
+                  <strong>{{ row.name }}</strong>
+                </div>
+              </td>
+              <td>
+                <div class="dept-cell">
+                  <strong>{{ row.department }}</strong>
+                  <small v-if="row.subDept">| {{ row.subDept }}</small>
+                </div>
+              </td>
+              <td>{{ row.position }}</td>
+              <td>
+                <div class="ratio-cell">
+                  <strong>{{ row.attendance }}<i></i></strong>
+                  <span>/ {{ row.expected }}</span>
+                </div>
+              </td>
+              <td>{{ row.late }}</td>
+              <td>{{ row.absent }}</td>
+              <td>
+                <span class="status-pill" :class="statusClass(row.status)">
+                  <i></i>{{ row.statusLabel }}
+                </span>
+              </td>
+              <td class="col-action">
+                <el-button type="text" size="mini" class="is-link">查看</el-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+  </main>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+
+export default {
+  name: 'AttendanceStats',
+  data() {
+    return {
+      loading: false,
+      filter: {
+        month: '2026年8月',
+        department: '全部部门'
+      },
+      monthOptions: ['2026年8月', '2026年7月', '2026年6月', '2026年5月'],
+      departmentOptions: ['全部部门', '市场部', '产品部', '研发中心', '生产部', '仓储部'],
+      detailRows: [
+        {
+          id: 1, name: '张三', department: '市场部', subDept: '市场经理',
+          position: '市场经理', attendance: 22, expected: 22,
+          late: '正常', absent: '矿工', status: 'normal', statusLabel: '正常'
+        },
+        {
+          id: 2, name: '李四', department: '市场部', subDept: '市场经理',
+          position: '市场经理', attendance: 22, expected: 22,
+          late: '正常', absent: '矿工', status: 'normal', statusLabel: '正常'
+        },
+        {
+          id: 3, name: '王五', department: '市场部', subDept: '产品经理',
+          position: '产品经理', attendance: 21, expected: 22,
+          late: '0', absent: '1', status: 'normal', statusLabel: '正常'
+        },
+        {
+          id: 4, name: '赵六', department: '产品部', subDept: '产品经理',
+          position: '产品经理', attendance: 21, expected: 22,
+          late: '0', absent: '1', status: 'normal', statusLabel: '正常'
+        },
+        {
+          id: 5, name: '钱七', department: '产品部', subDept: '产品助理',
+          position: '产品助理', attendance: 20, expected: 22,
+          late: '2', absent: '0', status: 'warning', statusLabel: '迟到'
+        },
+        {
+          id: 6, name: '孙八', department: '研发中心', subDept: '前端工程师',
+          position: '前端工程师', attendance: 22, expected: 22,
+          late: '正常', absent: '正常', status: 'normal', statusLabel: '正常'
+        },
+        {
+          id: 7, name: '周九', department: '研发中心', subDept: '后端工程师',
+          position: '后端工程师', attendance: 21, expected: 22,
+          late: '1', absent: '0', status: 'normal', statusLabel: '正常'
+        },
+        {
+          id: 8, name: '吴十', department: '生产部', subDept: '车间主任',
+          position: '车间主任', attendance: 22, expected: 22,
+          late: '正常', absent: '正常', status: 'normal', statusLabel: '正常'
+        }
+      ],
+      charts: {
+        donut: null,
+        line: null,
+        bar: null
+      }
+    };
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.renderPunchDonut();
+      this.renderPunchLine();
+      this.renderExceptionBar();
+      window.addEventListener('resize', this.resizeCharts);
+    });
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.resizeCharts);
+    Object.values(this.charts).forEach((chart) => chart && chart.dispose());
+  },
+  methods: {
+    statusClass(status) {
+      return ({
+        normal: 'success',
+        warning: 'pending',
+        danger: 'danger'
+      })[status] || 'muted';
+    },
+    exportData() {
+      this.$message.success('已导出考勤统计数据');
+    },
+    exportTable() {
+      this.$message.success('已导出考勤明细');
+    },
+    resizeCharts() {
+      Object.values(this.charts).forEach((chart) => chart && chart.resize());
+    },
+    tooltipStyle() {
+      return {
+        backgroundColor: 'rgba(35,45,61,.94)',
+        borderWidth: 0,
+        padding: [8, 12],
+        textStyle: { color: '#fff', fontSize: 12 },
+        extraCssText: 'border-radius:6px;box-shadow:0 8px 20px rgba(22,34,52,.18)'
+      };
+    },
+    renderPunchDonut() {
+      this.charts.donut = echarts.init(this.$refs.punchDonut);
+      this.charts.donut.setOption({
+        tooltip: {
+          trigger: 'item',
+          formatter: '{b}<br/>人数:{c} 人<br/>占比:{d}%',
+          ...this.tooltipStyle()
+        },
+        series: [
+          {
+            type: 'pie',
+            radius: ['62%', '82%'],
+            center: ['50%', '50%'],
+            avoidLabelOverlap: false,
+            startAngle: 90,
+            label: { show: false },
+            labelLine: { show: false },
+            itemStyle: {
+              borderColor: '#fff',
+              borderWidth: 4
+            },
+            data: [
+              { value: 10, name: '正常', itemStyle: { color: '#1768e5' } },
+              { value: 13, name: '异常', itemStyle: { color: '#d55752' } }
+            ],
+            emphasis: {
+              scale: false,
+              itemStyle: {
+                shadowBlur: 12,
+                shadowColor: 'rgba(23,104,229,.25)'
+              }
+            }
+          }
+        ],
+        graphic: [
+          {
+            type: 'text',
+            left: 'center',
+            top: '38%',
+            style: { text: '正常 10', fill: '#24344b', fontSize: 12, fontWeight: 500 }
+          },
+          {
+            type: 'text',
+            left: 'center',
+            top: '52%',
+            style: { text: '异常 13', fill: '#d55752', fontSize: 12, fontWeight: 500 }
+          }
+        ]
+      });
+    },
+    renderPunchLine() {
+      this.charts.line = echarts.init(this.$refs.punchLine);
+      this.charts.line.setOption({
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'line', lineStyle: { color: '#dfe5ec' } },
+          ...this.tooltipStyle()
+        },
+        grid: { top: 18, right: 14, bottom: 28, left: 38 },
+        xAxis: {
+          type: 'category',
+          data: ['4月', '5月', '6月', '半月', '9月', '1月', '当月'],
+          axisLine: { lineStyle: { color: '#dfe5ec' } },
+          axisTick: { show: false },
+          axisLabel: { color: '#8490a3', fontSize: 11 }
+        },
+        yAxis: {
+          type: 'value',
+          min: 0,
+          max: 1200,
+          interval: 300,
+          axisLine: { show: false },
+          axisTick: { show: false },
+          splitLine: { lineStyle: { color: '#eef0f4' } },
+          axisLabel: { color: '#9aa4b2', fontSize: 10 }
+        },
+        series: [
+          {
+            name: '打卡人数',
+            type: 'line',
+            smooth: true,
+            symbol: 'circle',
+            symbolSize: 8,
+            lineStyle: { width: 2.5, color: '#1768e5' },
+            itemStyle: { color: '#1768e5', borderColor: '#fff', borderWidth: 2 },
+            areaStyle: {
+              color: {
+                type: 'linear',
+                x: 0, y: 0, x2: 0, y2: 1,
+                colorStops: [
+                  { offset: 0, color: 'rgba(23,104,229,.35)' },
+                  { offset: 1, color: 'rgba(23,104,229,.02)' }
+                ]
+              }
+            },
+            data: [380, 520, 720, 580, 950, 760, 880]
+          }
+        ]
+      });
+    },
+    renderExceptionBar() {
+      this.charts.bar = echarts.init(this.$refs.exceptionBar);
+      this.charts.bar.setOption({
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' },
+          ...this.tooltipStyle()
+        },
+        grid: { top: 18, right: 18, bottom: 26, left: 38 },
+        xAxis: {
+          type: 'category',
+          data: ['迟到 2', '迟到 3', '早退', '缺卡 4', '旷工 4', '矿工', '8-8', '其他 8', '当月 2'],
+          axisLine: { lineStyle: { color: '#dfe5ec' } },
+          axisTick: { show: false },
+          axisLabel: { color: '#8490a3', fontSize: 11, interval: 0 }
+        },
+        yAxis: {
+          type: 'value',
+          axisLine: { show: false },
+          axisTick: { show: false },
+          splitLine: { lineStyle: { color: '#eef0f4' } },
+          axisLabel: { color: '#9aa4b2', fontSize: 10 }
+        },
+        series: [
+          {
+            type: 'bar',
+            barWidth: 22,
+            data: [2, 3, 5.5, 3, 4, 2.5, 2, 3, 1.5],
+            itemStyle: {
+              color: '#1768e5',
+              borderRadius: [4, 4, 0, 0]
+            }
+          }
+        ]
+      });
+    }
+  }
+};
+</script>
+
+<style src="../../../styles/views/attendance/index.scss" lang="scss" scoped></style>
+<style lang="scss" scoped>
+.stats-page { padding: 18px; }
+
+/* 顶部筛选条 */
+.stats-header-card {
+  border-radius: 12px;
+  margin-bottom: 16px;
+}
+.stats-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 14px 20px;
+}
+.stats-header-title h1 {
+  margin: 0;
+  font-size: 18px;
+  color: #24344b;
+  font-weight: 600;
+}
+.stats-header-title small {
+  display: block;
+  margin-top: 2px;
+  color: #8d99a9;
+  font-size: 11px;
+}
+.stats-header-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.stats-header-actions .el-select {
+  width: 160px;
+}
+
+/* 指标卡片 */
+.stats-metric { margin-bottom: 16px; }
+.stats-metric .metric-card { min-height: 90px; }
+.stats-metric .metric-copy strong em {
+  margin-left: 3px;
+  color: #8d99a9;
+  font-size: 11px;
+  font-style: normal;
+  font-weight: 400;
+}
+.stats-metric .metric-copy p {
+  margin: 4px 0 0;
+  color: #94a1b1;
+  font-size: 11px;
+}
+.metric-tail {
+  font-size: 28px;
+  color: #dde3eb;
+  margin-left: auto;
+}
+
+/* 图表区 */
+.stats-charts {
+  display: grid;
+  grid-template-columns: 1.5fr 1fr;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+.stats-charts .chart-panel {
+  border-radius: 12px;
+}
+.stats-charts .card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 14px 18px;
+  border-bottom: 1px solid #edf1f6;
+}
+.stats-charts .card-head h2 {
+  margin: 0;
+  font-size: 15px;
+  color: #25364e;
+  font-weight: 600;
+}
+.card-link {
+  color: #8d99a9;
+  font-size: 11px;
+  cursor: pointer;
+}
+.card-link i { margin-left: 3px; font-size: 11px; }
+.card-link:hover { color: #1768e5; }
+
+.chart-panel--punch { display: flex; flex-direction: column; }
+.punch-body {
+  display: grid;
+  grid-template-columns: 200px 1fr;
+  gap: 0;
+  align-items: stretch;
+  padding: 14px 8px 14px 18px;
+}
+.punch-donut { height: 240px; }
+.punch-line { height: 240px; }
+
+.chart-panel--exception { display: flex; flex-direction: column; }
+.exception-bar { height: 280px; padding: 12px 12px 18px; }
+
+/* 明细表 */
+.stats-table-card { border-radius: 12px; }
+.stats-table-card .card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 14px 18px;
+  border-bottom: 1px solid #edf1f6;
+}
+.stats-table-card .card-head h2 {
+  margin: 0;
+  font-size: 15px;
+  color: #25364e;
+  font-weight: 600;
+}
+.stats-table-wrap { padding: 0; }
+.stats-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+  color: #3d4a5e;
+}
+.stats-table thead th {
+  height: 44px;
+  padding: 0 12px;
+  text-align: left;
+  color: #68778c;
+  background: #f6f8fb;
+  font-weight: 600;
+  border-bottom: 1px solid #edf0f3;
+}
+.stats-table tbody td {
+  padding: 12px;
+  border-bottom: 1px solid #edf0f3;
+  vertical-align: middle;
+}
+.stats-table tbody tr:last-child td { border-bottom: 0; }
+.stats-table tbody tr:hover { background: #f2f7ff; }
+
+.stats-table .col-name { width: 18%; }
+.stats-table .col-action { width: 100px; text-align: left; }
+.stats-table .col-action .el-button.is-link { color: #1768e5; }
+
+.stats-table .employee-cell { display: flex; align-items: center; gap: 10px; }
+.stats-table .employee-cell > span {
+  width: 30px; height: 30px;
+  display: grid; place-items: center;
+  border-radius: 50%;
+  color: #1768e5;
+  background: #eaf2ff;
+  font-size: 13px; font-weight: 600;
+  flex: none;
+}
+.stats-table .employee-cell strong {
+  color: #24344b;
+  font-size: 13px;
+  font-weight: 500;
+}
+.stats-table .dept-cell strong,
+.stats-table .dept-cell small {
+  display: inline;
+  color: #3d4a5e;
+  font-size: 12px;
+}
+.stats-table .dept-cell small {
+  color: #b0b8c2;
+  margin: 0 4px;
+}
+.stats-table .ratio-cell strong {
+  position: relative;
+  display: inline-block;
+  margin-right: 4px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #24344b;
+}
+.stats-table .ratio-cell strong i {
+  display: inline-block;
+  width: 1px;
+  height: 14px;
+  background: #d6dce4;
+  vertical-align: middle;
+  margin-left: 6px;
+}
+.stats-table .ratio-cell span { color: #8d99a9; font-size: 11px; }
+
+.stats-table .status-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 2px 10px;
+  border-radius: 12px;
+  font-size: 11px;
+  white-space: nowrap;
+  background: #f6f8fb;
+}
+.stats-table .status-pill i {
+  width: 6px; height: 6px;
+  border-radius: 50%;
+  background: #aab4c0;
+}
+.stats-table .status-pill.success { color: #168d69; background: #e7f7f1; }
+.stats-table .status-pill.success i { background: #22a477; }
+.stats-table .status-pill.pending { color: #c67d23; background: #fff2e4; }
+.stats-table .status-pill.pending i { background: #e6a23c; }
+.stats-table .status-pill.danger { color: #d55752; background: #fff0ef; }
+.stats-table .status-pill.danger i { background: #d85e58; }
+
+@media (max-width: 1100px) {
+  .stats-charts { grid-template-columns: 1fr; }
+  .punch-body { grid-template-columns: 1fr; }
+}
+@media (max-width: 760px) {
+  .stats-header { flex-direction: column; align-items: stretch; gap: 10px; }
+  .stats-header-actions { flex-wrap: wrap; }
+  .stats-header-actions .el-select { width: 100%; }
+}
+</style>

+ 1581 - 0
src/views/attendance/rule/index.vue

@@ -0,0 +1,1581 @@
+<template>
+  <main class="attendance-page rule-page" v-loading="loading">
+    <section class="workspace-card rule-card">
+      <div class="filter-bar filter-bar--rule">
+        <el-input v-model.trim="filter.keyword" prefix-icon="el-icon-search" clearable placeholder="打卡规则名称" />
+        <el-select v-model="filter.method" placeholder="打卡方式" clearable>
+          <el-option v-for="m in methodOptions" :key="m.value" :label="m.label" :value="m.value" />
+        </el-select>
+        <el-select v-model="filter.scope" placeholder="所属机构" clearable>
+          <el-option v-for="o in orgOptions" :key="o.value" :label="o.label" :value="o.value" />
+        </el-select>
+        <el-select v-model="filter.type" placeholder="规则类型" clearable>
+          <el-option v-for="t in ruleTypes" :key="t.value" :label="t.label" :value="t.value" />
+        </el-select>
+        <div class="filter-actions">
+          <el-button type="primary" icon="el-icon-search" @click="applyFilters">查询</el-button>
+          <el-button icon="el-icon-refresh-left" @click="resetFilters">重置</el-button>
+        </div>
+      </div>
+      <ele-pro-table ref="table" row-key="id" :columns="columns" :datasource="filteredRecords" :need-page="true"
+        :page-size="10" :toolkit="['size', 'columns', 'fullscreen']" stripe class="rule-table">
+        <template slot="toolbar">
+          <el-button size="small" type="primary" icon="el-icon-plus" @click="openCreate">新增打卡规则</el-button>
+        </template>
+        <template v-slot:name="{ row }">
+          <div class="name-cell">
+            <strong>{{ row.name }}</strong>
+            <small>{{ row.id }} · {{ scopeLabel(row.scope) }}</small>
+          </div>
+        </template>
+        <template v-slot:type="{ row }"><span class="tag-pill" :class="typeTone(row.type)">{{ ruleTypeLabel(row.type)
+            }}</span></template>
+        <template v-slot:workTime="{ row }">{{ row.workTime }}</template>
+        <template v-slot:holiday="{ row }">{{ row.holiday }}</template>
+        <template v-slot:restDays="{ row }">{{ row.restDays.join("、") }}</template>
+        <template v-slot:methods="{ row }">
+          <div class="method-cell">
+            <span v-for="m in row.methods" :key="m" class="tag-pill" :class="methodTone(m)">{{ methodLabel(m) }}</span>
+          </div>
+        </template>
+        <template v-slot:location="{ row }">{{ row.location }}</template>
+        <template v-slot:wifi="{ row }">{{ row.wifi }}</template>
+        <template v-slot:devices="{ row }">{{ row.devices }}</template>
+        <template v-slot:overtime="{ row }">
+          <span class="status-pill" :class="row.overtime === '开启' ? 'success' : 'muted'">
+            <i></i>{{ row.overtime }}
+          </span>
+        </template>
+        <template v-slot:overtimeDuration="{ row }"><small class="cell-multiline">{{ row.overtimeDuration
+            }}</small></template>
+        <template v-slot:makeup="{ row }"><span class="tag-pill"
+            :class="row.makeup === '允许' ? 'tag-green' : 'tag-gray'">{{ row.makeup }}</span></template>
+        <template v-slot:leaveMakeup="{ row }">{{ row.leaveMakeup }}</template>
+        <template v-slot:status="{ row }"><el-switch :value="row.enabled" @change="toggle(row)" /></template>
+        <template v-slot:action="{ row }">
+          <el-button type="text" size="mini" @click="viewDetail(row)">查看</el-button>
+          <el-button type="text" size="mini" @click="openEdit(row)">编辑</el-button>
+          <el-button type="text" size="mini" class="is-danger" @click="handleDelete(row)">删除</el-button>
+        </template>
+        <template slot="empty">
+          <el-button size="small" type="primary" @click="openCreate">新增打卡规则</el-button>
+        </template>
+      </ele-pro-table>
+    </section>
+    <ele-modal :title="dialogTitle" :visible.sync="dialogVisible" width="960px"
+      custom-class="rule-dialog ele-dialog-form"
+      :close-on-click-modal="false" :maxable="true" append-to-body>
+      <div class="dialog-intro">
+        <div class="intro-icon"><i class="el-icon-time"></i></div>
+        <div>
+          <strong>{{ dialogTitle === '编辑打卡规则' ? '维护打卡规则' : '创建新的打卡规则' }}</strong>
+          <p>配置打卡方式、上下班时间、节假日、加班与补卡等规则,保存后同步到考勤策略。</p>
+        </div>
+      </div>
+      <div class="rule-dialog-tabs">
+        <button v-for="t in dialogTabs" :key="t.key" :class="['tab-chip', { active: activeTab === t.key }]"
+          type="button" @click="activeTab = t.key">{{ t.label }}</button>
+      </div>
+      <div class="rule-dialog-body">
+        <rule-form-panels
+          :form="form"
+          :active-tab="activeTab"
+          :week-options="weekOptions"
+          :time-options="timeOptions"
+          :related-target-options="relatedTargetOptions"
+          :assistant-user-options="assistantUserOptions"
+          :timezone-options="timezoneOptions"
+          :reminder-options="reminderOptions"
+          :rules="rules"
+          @add-location="addLocation"
+          @add-wifi="addWifi"
+          @add-device="openDeviceCard"
+          @device-view="viewDevice"
+          @device-edit="editDevice"
+          @device-test="testDevice"
+          @device-sync="syncDevice"
+          @device-restart="restartDevice"
+          @device-delete="deleteDevice"
+          @device-log="viewDeviceLog"
+        />
+      </div>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="save">保存规则</el-button>
+      </span>
+    </ele-modal>
+
+    <!-- 添加打卡位置 -->
+    <ele-modal title="添加打卡位置" :visible.sync="locationDialogVisible" width="420px" custom-class="modal"
+      :close-on-click-modal="false" append-to-body>
+      <el-form :model="locationForm" label-position="top">
+        <el-form-item label="地点名称" required>
+          <el-input v-model.trim="locationForm.name" placeholder="如:麓谷企业广场F2栋" clearable />
+        </el-form-item>
+        <el-form-item label="有效范围" required>
+          <el-select v-model="locationForm.range" placeholder="选择有效范围" style="width:100%">
+            <el-option v-for="r in rangeOptions" :key="r" :label="r" :value="r" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="locationDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmLocation">确定</el-button>
+      </span>
+    </ele-modal>
+
+    <!-- 添加打卡Wi-Fi -->
+    <ele-modal title="添加打卡Wi-Fi" :visible.sync="wifiDialogVisible" width="420px" custom-class="modal"
+      :close-on-click-modal="false" append-to-body>
+      <el-form :model="wifiForm" label-position="top">
+        <el-form-item label="WiFi 名称" required>
+          <el-input v-model.trim="wifiForm.ssid" placeholder="如:AIMILL-OFFICE" clearable />
+        </el-form-item>
+        <el-form-item label="MAC 地址" required>
+          <el-input v-model.trim="wifiForm.bssid" placeholder="如:00:1A:2B:3C:4D:5E" clearable />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="wifiDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmWifi">确定</el-button>
+      </span>
+    </ele-modal>
+
+    <!-- 新增/编辑考勤机 -->
+    <ele-modal :title="deviceDialogTitle" :visible.sync="deviceDialogVisible" width="520px" custom-class="modal"
+      :close-on-click-modal="false" append-to-body>
+      <el-form :model="deviceForm" :rules="deviceRules" ref="deviceFormRef" label-position="top">
+        <el-form-item label="设备名称" prop="name">
+          <el-input v-model.trim="deviceForm.name" placeholder="如:前台考勤机" clearable />
+        </el-form-item>
+        <el-form-item label="设备SN码" prop="sn">
+          <el-input v-model.trim="deviceForm.sn" placeholder="如:AIM-2025-0001" clearable />
+        </el-form-item>
+        <el-form-item label="通信端口" prop="port">
+          <el-input-number v-model="deviceForm.port" :min="1" :max="65535" controls-position="right"
+            style="width:100%" placeholder="默认 4370" />
+        </el-form-item>
+        <el-form-item label="设备类型" prop="deviceType">
+          <el-select v-model="deviceForm.deviceType" placeholder="选择设备类型" style="width:100%">
+            <el-option v-for="t in deviceTypeOptions" :key="t" :label="t" :value="t" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="安装位置">
+          <el-input v-model.trim="deviceForm.location" placeholder="如:麓谷企业广场F2栋 1楼前台" clearable />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="deviceForm.remark" type="textarea" :rows="2" placeholder="选填" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="deviceDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmDevice">确定</el-button>
+      </span>
+    </ele-modal>
+
+    <!-- 设备详情 / 日志 共用查看弹窗 -->
+    <ele-modal :title="viewDialogTitle" :visible.sync="viewDialogVisible" width="520px" custom-class="modal"
+      :close-on-click-modal="false" append-to-body>
+      <div v-if="viewingDevice" class="device-view">
+        <template v-if="viewMode === 'detail'">
+          <div class="view-row"><span>设备名称</span><strong>{{ viewingDevice.name }}</strong></div>
+          <div class="view-row"><span>SN码</span><strong>{{ viewingDevice.sn }}</strong></div>
+          <div class="view-row"><span>IP地址</span><strong>{{ viewingDevice.ip }}</strong></div>
+          <div class="view-row"><span>通信端口</span><strong>{{ viewingDevice.port }}</strong></div>
+          <div class="view-row"><span>设备类型</span><strong>{{ viewingDevice.deviceType }}</strong></div>
+          <div class="view-row"><span>安装位置</span><strong>{{ viewingDevice.location || '—' }}</strong></div>
+          <div class="view-row"><span>在线状态</span><strong>{{ viewingDevice.online ? '在线' : '离线' }}</strong></div>
+          <div class="view-row"><span>在线时长</span><strong>{{ viewingDevice.onlineDuration }}</strong></div>
+          <div class="view-row"><span>绑定部门</span><strong>{{ viewingDevice.department }}</strong></div>
+          <div class="view-row"><span>最后同步时间</span><strong>{{ viewingDevice.lastSync }}</strong></div>
+          <div class="view-row"><span>备注</span><strong>{{ viewingDevice.remark || '—' }}</strong></div>
+        </template>
+        <template v-else>
+          <div class="device-log-list">
+            <div v-for="(log, i) in deviceLogs" :key="i" class="log-item">
+              <span class="log-time">{{ log.time }}</span>
+              <span class="log-tag" :class="log.level">{{ log.levelText }}</span>
+              <span class="log-msg">{{ log.msg }}</span>
+            </div>
+          </div>
+        </template>
+      </div>
+      <span slot="footer">
+        <el-button @click="viewDialogVisible = false">关闭</el-button>
+      </span>
+    </ele-modal>
+
+    <el-drawer custom-class="rule-drawer" :title="detailRow ? '打卡规则详情 - ' + detailRow.name : ''" :visible.sync="detailVisible" direction="rtl"
+      size="640px" append-to-body>
+      <div v-if="detailRow" style="padding:0 18px 18px;">
+        <div class="employee-summary">
+          <span><i class="el-icon-time"></i></span>
+          <div>
+            <h3>{{ detailRow.name }}</h3>
+            <p>{{ detailRow.id }} · {{ scopeLabel(detailRow.scope) }} · {{ ruleTypeLabel(detailRow.type) }}</p>
+          </div>
+        </div>
+        <div class="info-grid info-grid--two">
+          <div><span>上下班时间</span><strong>{{ detailRow.workTime }}</strong></div>
+          <div><span>节假日与特殊日期</span><strong>{{ detailRow.holiday }}</strong></div>
+          <div><span>休息日</span><strong>{{ detailRow.restDays.join("、") }}</strong></div>
+          <div><span>打卡方式</span><strong>{{ detailRow.methods.map(methodLabel).join("、") }}</strong></div>
+          <div><span>打卡位置</span><strong>{{ detailRow.location }}</strong></div>
+          <div><span>打卡WiFi</span><strong>{{ detailRow.wifi }}</strong></div>
+          <div><span>考勤机</span><strong>{{ detailRow.devices }}</strong></div>
+          <div><span>加班规则</span><strong>{{ detailRow.overtime }}</strong></div>
+          <div><span>加班时长设置</span><strong>{{ detailRow.overtimeDuration }}</strong></div>
+          <div><span>补卡设置</span><strong>{{ detailRow.makeup }}</strong></div>
+          <div><span>请假时补卡设置</span><strong>{{ detailRow.leaveMakeup }}</strong></div>
+          <div><span>最近更新</span><strong>{{ detailRow.updatedAt }}</strong></div>
+        </div>
+      </div>
+    </el-drawer>
+  </main>
+</template>
+
+<script>
+import { PUNCH_RULES, PUNCH_METHOD_OPTIONS, ORG_OPTIONS, RULE_TYPES, ruleTypeLabel, methodLabel, methodTone } from '../mock';
+import RuleFormPanels from '../components/RuleFormPanels.vue';
+
+export default {
+  name: 'AttendanceRule',
+  components: { RuleFormPanels },
+  data() {
+    return {
+      loading: false,
+      methodOptions: PUNCH_METHOD_OPTIONS,
+      orgOptions: ORG_OPTIONS,
+      ruleTypes: RULE_TYPES,
+      filter: { keyword: '', method: '', scope: '', type: '' },
+      records: PUNCH_RULES.map(r => ({ ...r })),
+      detailRow: null,
+      detailVisible: false,
+      locationDialogVisible: false,
+      wifiDialogVisible: false,
+      locationForm: { name: '', range: '300米' },
+      wifiForm: { ssid: '', bssid: '' },
+      deviceDialogVisible: false,
+      deviceDialogTitle: '新增考勤机',
+      editingDeviceIndex: -1,
+      deviceForm: this.emptyDevice(),
+      deviceRules: {
+        name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
+        sn: [{ required: true, message: '请输入设备SN码', trigger: 'blur' }],
+        port: [{ required: true, message: '请输入通信端口', trigger: 'blur' }],
+        deviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }]
+      },
+      deviceTypeOptions: ['考勤机', '考勤门禁一体机', '人脸识别考勤机', '指纹考勤机', '刷卡考勤机', '混合识别考勤机'],
+      viewDialogVisible: false,
+      viewDialogTitle: '设备详情',
+      viewingDevice: null,
+      viewMode: 'detail',
+      deviceLogs: [],
+      departmentOptions: ['总部', '研发中心', '市场部', '运营部', '人事行政部', '财务部'],
+      dialogVisible: false,
+      dialogTitle: '新增打卡规则',
+      activeTab: 'method',
+      dialogTabs: [
+        { key: 'method', label: '打卡方式' },
+        { key: 'workTime', label: '上下班时间' },
+        { key: 'rest', label: '节假日' },
+        { key: 'overtime', label: '加班规则' },
+        { key: 'makeup', label: '补卡设置' },
+        { key: 'leave', label: '请假规则' },
+        { key: 'assistant', label: '助理管理设置' },
+        { key: 'outside', label: '外出打卡' }
+      ],
+      form: this.empty(),
+      rangeOptions: ['50米', '100米', '150米', '200米', '300米', '500米', '800米', '1000米'],
+      timeOptions: ["00:00","00:30","01:00","01:30","02:00","02:30","03:00","03:30","04:00","04:30","05:00","05:30","06:00","06:30","07:00","07:30","08:00","08:30","09:00","09:30","10:00","10:30","11:00","11:30","12:00","12:30","13:00","13:30","14:00","14:30","15:00","15:30","16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00","20:30","21:00","21:30","22:00","22:30","23:00","23:30","24:00"],
+      weekOptions: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
+      relatedTargetOptions: [
+        { value: 'ding', label: '钉钉考勤' },
+        { value: 'wecom', label: '企业微信' },
+        { value: 'device', label: '考勤机' },
+        { value: 'app', label: '自有 App' }
+      ],
+      punchWindowOptions: [
+        { value: '0-24', label: '全天 00:00 - 24:00' },
+        { value: '5-24', label: '05:00 - 24:00' },
+        { value: '6-22', label: '06:00 - 22:00' },
+        { value: '7-20', label: '07:00 - 20:00' }
+      ],
+      assistantUserOptions: ['李亚洪', '王小云', '赵磊', '钱敏', '徐建国', '黄芳'],
+      timezoneOptions: [
+        '(GMT+08:00)中国标准时间-北京',
+        '(GMT+00:00)世界标准时间 UTC',
+        '(GMT-05:00)美国东部时间-纽约',
+        '(GMT+09:00)日本标准时间-东京',
+        '(GMT+05:30)印度标准时间-新德里'
+      ],
+      reminderOptions: [
+        '上班前 10 分钟、下班准点',
+        '上班前 5 分钟、下班延后 5 分钟',
+        '仅上班前 10 分钟',
+        '仅下班延后 5 分钟',
+        '不提醒'
+      ],
+      halfDayOptions: [
+        { value: 'morning', label: '上午班 08:00 - 12:00' },
+        { value: 'afternoon', label: '下午班 13:00 - 18:00' },
+        { value: 'custom-am', label: '上午半天 07:30 - 12:00' },
+        { value: 'custom-pm', label: '下午半天 12:30 - 17:30' }
+      ],
+      rules: {
+        name: [{ required: true, message: '请输入打卡规则名称', trigger: 'blur' }],
+        type: [{ required: true, message: '请选择规则类型', trigger: 'change' }],
+        scope: [{ required: true, message: '请选择所属机构', trigger: 'change' }]
+      },
+      page: 1,
+      pageSize: 10,
+      columns: [
+        { type: 'index', label: '序号', width: 60, align: 'center', headerAlign: 'center' },
+        { prop: 'name', label: '打卡规则名称', minWidth: 200, slot: 'name' },
+        { prop: 'type', label: '规则类型', minWidth: 110, slot: 'type' },
+        { prop: 'workTime', label: '上下班时间', minWidth: 200, slot: 'workTime' },
+        { prop: 'holiday', label: '节假日与特殊日期', minWidth: 200, slot: 'holiday' },
+        { prop: 'restDays', label: '休息日', minWidth: 140, slot: 'restDays' },
+        { prop: 'methods', label: '打卡方式', minWidth: 180, slot: 'methods' },
+        { prop: 'location', label: '打卡位置', minWidth: 200, slot: 'location' },
+        { prop: 'wifi', label: '打卡WiFi', minWidth: 160, slot: 'wifi' },
+        { prop: 'devices', label: '考勤机', minWidth: 200, slot: 'devices' },
+        { prop: 'overtime', label: '加班规则', minWidth: 90, slot: 'overtime', align: 'center', headerAlign: 'center' },
+        { prop: 'overtimeDuration', label: '加班时长设置', minWidth: 220, slot: 'overtimeDuration' },
+        { prop: 'makeup', label: '补卡设置', minWidth: 100, slot: 'makeup', align: 'center', headerAlign: 'center' },
+        { prop: 'leaveMakeup', label: '请假时补卡设置', minWidth: 130, slot: 'leaveMakeup' },
+        { prop: 'action', label: '操作', width: 180, slot: 'action', align: 'left', headerAlign: 'left' }
+      ]
+    };
+  },
+  computed: {
+    filteredRecords() {
+      return this.records.filter(r => {
+        if (this.filter.keyword && !r.name.includes(this.filter.keyword)) return false;
+        if (this.filter.method && !r.methods.includes(this.filter.method)) return false;
+        if (this.filter.scope && r.scope !== this.filter.scope) return false;
+        if (this.filter.type && r.type !== this.filter.type) return false;
+        return true;
+      });
+    },
+    enabledCount() {
+      return this.records.filter(r => r.enabled).length;
+    },
+  },
+  methods: {
+    ruleTypeLabel,
+    methodLabel,
+    methodTone,
+    scopeLabel(scope) {
+      const o = this.orgOptions.find(x => x.value === scope);
+      return o ? o.label : scope;
+    },
+    typeTone(type) {
+      return ({ fixed: 'tag-blue', flexible: 'tag-purple', comprehensive: 'tag-amber' })[type] || 'tag-gray';
+    },
+    empty() {
+      return {
+        id: '', name: '', scope: 'HQ', type: 'fixed',
+        workStart: '09:00', workEnd: '18:00', crossDay: false,
+        holiday: '跟随国家法定节假日', restDays: ['周六', '周日'],
+        method: 'phone', locations: [], outOfRange: '异常',
+        wifis: [], devices: [], overtimeUnit: '小时',
+        overtimeRound: '四舍五入',
+        overtimeDecimal: '1位小数',
+        overtimeDayHours: 9,
+        overtime: '开启',
+        workOvertimeEnabled: true,
+        workOvertimePeriod: '下班后到次日上班前',
+        workCalcMethod: '按打卡时长计算',
+        workDeductRest: false,
+        workCompOrPay: true,
+        restOvertimeEnabled: true,
+        restOvertimePeriod: '全天',
+        restCalcMethod: '按审批时长计算',
+        restDeductRest: false,
+        restCompOrPay: true,
+        holidayOvertimeEnabled: true,
+        holidayOvertimePeriod: '全天',
+        holidayCalcMethod: '按审批时长计算',
+        holidayDeductRest: false,
+        holidayCompOrPay: true,
+        overtimeStart: 30, overtimeDuration: '平日 1.5x / 周末 2x / 节假日 3x',
+        makeupEnabled: true,
+        makeupType: '缺卡/旷工',
+        makeupTimeLimit: '不限制',
+        makeupReminder: '启用',
+        makeupReminderTime: '09:00',
+        makeupMaxPerMonth: '3次',
+        makeupDeadline: '不设置',
+        approvalPunchEnabled: true,
+        workDays: ['周一', '周二', '周三', '周四', '周五'],
+        workShifts: [{ key: 'WS1', start: '09:00', end: '18:00', startOpen: '是', endOpen: '是', lunchBreaks: [{ key: 'L1', start: '12:00', end: '13:00' }] }],
+        relatedTarget: 'ding',
+        punchWindow: '5-24',
+        punchStart: '05:00',
+        punchEnd: '24:00',
+        flexEnabled: false,
+        lateComp: false,
+        halfDayRange: 'morning',
+        halfStart: '08:00',
+        halfEnd: '12:00',
+        leaveNeedPunch: true,
+        leavePunchWindow: '不限制',
+        leaveRules: [
+          { category: '请假', name: '年假', type: '带薪假', scope: '全体员工', condition: '在职年限≥1年', quota: '5天', salary: '不扣薪资', approval: '待审核', status: '未生效', action: '修改' },
+          { category: '请假', name: '事假', type: '扣除薪资', scope: '全体员工', condition: '入职就可', quota: '0天', salary: '按天扣除', approval: '已审核', status: '已生效', action: '已归档' },
+          { category: '请假', name: '病假', type: '带薪假', scope: '全体员工', condition: '入职就可', quota: '0.3天', salary: '按天扣除40%', approval: '已审核', status: '已生效', action: '已归档' },
+          { category: '请假', name: '婚假', type: '特殊假期', scope: '全体员工', condition: '入职就可', quota: '10天', salary: '不扣薪资', approval: '已审核', status: '已生效', action: '已归档' },
+          { category: '请假', name: '调休', type: '加班调休', scope: '全体员工', condition: '入职就可', quota: '0天', salary: '不扣薪资', approval: '已审核', status: '已生效', action: '已归档' },
+          { category: '请假', name: '产假', type: '特殊假期', scope: '转正员工', condition: '入职就可', quota: '25天', salary: '不扣薪资', approval: '已审核', status: '已生效', action: '已归档' }
+        ],
+        assistantEnabled: true,
+        assistantExemptUsers: [],
+        timezone: '(GMT+08:00)中国标准时间-北京',
+        reminderOffset: '上班前 10 分钟、下班准点',
+        asstOutOfRange: '记录为地点异常',
+        faceRecognition: '启用',
+        photoEachPunch: false,
+        faceEachPunch: false,
+        outsourceSync: false,
+        outsideEnabled: true,
+        outsideMode: 'time',
+        outsideStart: '09:00',
+        outsideEnd: '18:00',
+        outsideAddress: '不限制',
+        outsidePhoto: true,
+        reportStart: '09:00',
+        reportEnd: '10:00',
+        enabled: true, updatedAt: ''
+      };
+    },
+    addLocation() {
+      this.locationForm = { name: '', range: '300米' };
+      this.locationDialogVisible = true;
+    },
+    confirmLocation() {
+      if (!this.locationForm.name) return this.$message.warning('请输入地点名称');
+      this.form.locations.push({ name: this.locationForm.name, range: this.locationForm.range });
+      this.locationDialogVisible = false;
+      this.$message.success('已添加打卡位置');
+    },
+    addWifi() {
+      this.wifiForm = { ssid: '', bssid: '' };
+      this.wifiDialogVisible = true;
+    },
+    confirmWifi() {
+      if (!this.wifiForm.ssid) return this.$message.warning('请输入 WiFi 名称');
+      if (!this.wifiForm.bssid) return this.$message.warning('请输入 MAC 地址');
+      this.form.wifis.push({ ssid: this.wifiForm.ssid, bssid: this.wifiForm.bssid });
+      this.wifiDialogVisible = false;
+      this.$message.success('已添加打卡Wi-Fi');
+    },
+    applyFilters() { },
+    resetFilters() { this.filter = { keyword: '', method: '', scope: '', type: '' }; },
+    exportRules() { this.$message.success('已导出打卡规则'); },
+    openCreate() {
+      this.form = this.empty();
+      this.activeTab = 'method';
+      this.dialogTitle = '新增打卡规则';
+      this.dialogVisible = true;
+    },
+    openEdit(row) {
+      const base = this.empty();
+      const formData = {
+        ...base,
+        ...row,
+        overtimeOn: row.overtime === '开启',
+        locations: row.location ? [{ name: row.location, range: '300米' }] : [],
+        wifis: row.wifi ? [{ ssid: row.wifi, bssid: '-' }] : []
+      };
+      // 老数据没有 workShifts 时,尝试从 workTime 字符串反解析
+      if (!formData.workShifts && row.workTime) {
+        const m = row.workTime.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
+        if (m) {
+          formData.workShifts = [{ key: 'WS1', start: m[1], end: m[2], startOpen: '是', endOpen: '是', lunchBreaks: [{ key: 'L1', start: '12:00', end: '13:00' }] }];
+        }
+      }
+      this.form = formData;
+      this.activeTab = 'method';
+      this.dialogTitle = '编辑打卡规则';
+      this.dialogVisible = true;
+    },
+    toggle(row) {
+      row.enabled = !row.enabled;
+      this.$message.success('已' + (row.enabled ? '启用' : '停用') + ':' + row.name);
+    },
+    openDeviceCard() {
+      this.deviceForm = this.emptyDevice();
+      this.editingDeviceIndex = -1;
+      this.deviceDialogTitle = '新增考勤机';
+      this.deviceDialogVisible = true;
+      this.$nextTick(() => {
+        if (this.$refs.deviceFormRef) this.$refs.deviceFormRef.clearValidate();
+      });
+    },
+    confirmDevice() {
+      this.$refs.deviceFormRef.validate(valid => {
+        if (!valid) return;
+        const f = this.deviceForm;
+        if (this.editingDeviceIndex > -1) {
+          const target = this.form.devices[this.editingDeviceIndex];
+          Object.assign(target, {
+            name: f.name, sn: f.sn, port: f.port,
+            deviceType: f.deviceType, location: f.location, remark: f.remark
+          });
+          this.$message.success('已更新考勤机:' + f.name);
+        } else {
+          const now = new Date();
+          const lastSync = now.toISOString().slice(0, 19).replace('T', ' ');
+          const ip = '192.168.' + Math.floor(Math.random() * 250 + 1) + '.' + Math.floor(Math.random() * 250 + 1);
+          this.form.devices.push({
+            id: 'DEV' + String(Date.now()).slice(-6),
+            name: f.name,
+            sn: f.sn,
+            port: f.port || 4370,
+            deviceType: f.deviceType,
+            location: f.location,
+            remark: f.remark,
+            ip,
+            online: true,
+            onlineDuration: '0小时0分钟',
+            department: this.departmentOptions[Math.floor(Math.random() * this.departmentOptions.length)],
+            lastSync
+          });
+          this.$message.success('已添加考勤机:' + f.name);
+        }
+        this.deviceDialogVisible = false;
+      });
+    },
+    emptyDevice() {
+      return { name: '', sn: '', port: 4370, deviceType: '', location: '', remark: '' };
+    },
+    viewDevice(row) {
+      this.viewingDevice = row;
+      this.viewMode = 'detail';
+      this.viewDialogTitle = '设备详情';
+      this.viewDialogVisible = true;
+    },
+    editDevice(row) {
+      const idx = this.form.devices.findIndex(d => d === row);
+      if (idx < 0) return;
+      this.editingDeviceIndex = idx;
+      this.deviceForm = { ...row };
+      this.deviceDialogTitle = '编辑考勤机';
+      this.deviceDialogVisible = true;
+      this.$nextTick(() => {
+        if (this.$refs.deviceFormRef) this.$refs.deviceFormRef.clearValidate();
+      });
+    },
+    testDevice(row) {
+      const loading = this.$message({ message: '正在测试连接 ' + row.name + ' ...', type: 'info', duration: 0 });
+      setTimeout(() => {
+        loading.close();
+        this.$message.success(row.name + ' 连接测试成功');
+        const target = this.form.devices.find(d => d === row);
+        if (target) {
+          target.online = true;
+          target.lastSync = new Date().toISOString().slice(0, 19).replace('T', ' ');
+        }
+      }, 800);
+    },
+    syncDevice(row) {
+      const loading = this.$message({ message: '正在同步 ' + row.name + ' 的考勤数据...', type: 'info', duration: 0 });
+      setTimeout(() => {
+        loading.close();
+        this.$message.success(row.name + ' 数据同步完成');
+        const target = this.form.devices.find(d => d === row);
+        if (target) {
+          target.online = true;
+          target.lastSync = new Date().toISOString().slice(0, 19).replace('T', ' ');
+          target.onlineDuration = this.bumpDuration(target.onlineDuration);
+        }
+      }, 1000);
+    },
+    restartDevice(row) {
+      this.$confirm('确认重启考勤机「' + row.name + '」?设备将在 30 秒后恢复在线。', '提示', { type: 'warning' })
+        .then(() => {
+          const target = this.form.devices.find(d => d === row);
+          if (target) target.online = false;
+          this.$message.success(row.name + ' 正在重启...');
+          setTimeout(() => {
+            if (target) {
+              target.online = true;
+              target.lastSync = new Date().toISOString().slice(0, 19).replace('T', ' ');
+              target.onlineDuration = '0小时0分钟';
+            }
+            this.$message.success(row.name + ' 已重启完成');
+          }, 2000);
+        }).catch(() => { });
+    },
+    deleteDevice(row) {
+      this.$confirm('确认删除考勤机「' + row.name + '」?', '提示', { type: 'warning' })
+        .then(() => {
+          this.form.devices = this.form.devices.filter(d => d !== row);
+          this.$message.success('已删除考勤机:' + row.name);
+        }).catch(() => { });
+    },
+    viewDeviceLog(row) {
+      this.viewingDevice = row;
+      this.viewMode = 'log';
+      this.viewDialogTitle = row.name + ' - 操作日志';
+      this.deviceLogs = this.buildMockLogs(row);
+      this.viewDialogVisible = true;
+    },
+    buildMockLogs(row) {
+      const now = Date.now();
+      const mk = (offsetMin, level, levelText, msg) => {
+        const t = new Date(now - offsetMin * 60000);
+        return {
+          time: t.toISOString().slice(0, 19).replace('T', ' '),
+          level, levelText, msg
+        };
+      };
+      return [
+        mk(1, 'success', '成功', '设备 ' + row.name + ' 上传打卡记录 32 条'),
+        mk(5, 'info', '信息', '心跳包上报,设备运行正常'),
+        mk(15, 'success', '成功', '系统完成与 ' + row.sn + ' 的时钟同步'),
+        mk(38, 'warn', '告警', '检测到短时网络抖动,已自动重连'),
+        mk(60, 'info', '信息', '设备 ' + row.name + ' 启动完成,开始服务'),
+        mk(120, 'success', '成功', '固件版本校验通过(当前 v2.4.1)')
+      ];
+    },
+    bumpDuration(prev) {
+      // 把 "X小时Y分钟" + 1 分钟,返回新值
+      const m = (prev || '').match(/(\d+)小时(\d+)分钟/);
+      if (!m) return '0小时1分钟';
+      let h = parseInt(m[1], 10), mi = parseInt(m[2], 10) + 1;
+      if (mi >= 60) { h += 1; mi -= 60; }
+      return h + '小时' + mi + '分钟';
+    },
+    viewDetail(row) {
+      this.detailRow = row;
+      this.detailVisible = true;
+    },
+    save() {
+      const data = { ...this.form };
+      data.overtime = data.overtimeOn ? '开启' : '关闭';
+      const firstShift = (data.workShifts && data.workShifts[0]) || {};
+      data.workStart = firstShift.start || data.workStart || '';
+      data.workEnd = firstShift.end || data.workEnd || '';
+      data.crossDay = firstShift.crossDay || false;
+      data.workTime = (data.workStart || '') + ' - ' + (data.workEnd || '') + (data.crossDay ? '(跨日)' : '');
+      const methods = [];
+      if (data.method === 'phone' || data.method === 'phone-device') {
+        if (data.locations.length) methods.push('gps');
+        if (data.wifis.length) methods.push('wifi');
+      }
+      if (data.method === 'device' || data.method === 'phone-device') methods.push('face');
+      data.methods = methods.length ? methods : ['gps', 'face'];
+      data.location = data.locations.map(l => l.name + '(' + l.range + ')').join('、') || '-';
+      data.wifi = data.wifis.map(w => w.ssid).join('、') || '-';
+      data.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ');
+      if (!data.id) {
+        data.id = 'PR' + String(Date.now()).slice(-4);
+        this.records.unshift(data);
+        this.$message.success('打卡规则已新增');
+      } else {
+        const idx = this.records.findIndex(r => r.id === data.id);
+        if (idx > -1) this.records.splice(idx, 1, data);
+        this.$message.success('打卡规则已保存');
+      }
+      this.dialogVisible = false;
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除「' + row.name + '」?', '提示', { type: 'warning' })
+        .then(() => {
+          this.records = this.records.filter(r => r.id !== row.id);
+          this.$message.success('已删除');
+        }).catch(() => { });
+    }
+  }
+};
+</script>
+
+<style>
+/* ===== dialog 主体(ele-modal custom-class="rule-dialog")===== */
+.rule-dialog .el-dialog__header {
+  padding: 16px 20px 14px;
+  border-bottom: 1px solid #edf0f5;
+}
+
+.rule-dialog .el-dialog__header .el-dialog__title {
+  color: #24344b;
+  font-size: 17px;
+  font-weight: 600;
+}
+
+.rule-dialog .el-dialog__headerbtn {
+  top: 16px;
+  right: 18px;
+  width: 30px;
+  height: 30px;
+  border-radius: 6px;
+  transition: background 0.2s;
+}
+
+.rule-dialog .el-dialog__headerbtn:hover {
+  background: #f1f4f7;
+}
+
+.rule-dialog .el-dialog__headerbtn .el-dialog__close {
+  color: #7b899a;
+  font-size: 17px;
+}
+
+.rule-dialog .el-dialog__body {
+  padding: 14px 18px 18px;
+}
+
+.rule-dialog .dialog-intro {
+  margin: 0 0 12px;
+  padding: 12px 15px;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  border: 1px solid #dceaff;
+  border-radius: 12px;
+  background: linear-gradient(120deg, #f4f9ff, #f8fbff);
+}
+
+.rule-dialog .dialog-intro strong {
+  display: block;
+  color: #24344b;
+  font-size: 14px;
+}
+
+.rule-dialog .dialog-intro p {
+  margin: 5px 0 0;
+  color: #8390a5;
+  font-size: 12px;
+}
+
+.rule-dialog .intro-icon {
+  flex: none;
+  width: 38px;
+  height: 38px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 11px;
+  color: #fff;
+  background: linear-gradient(135deg, #1877f2, #43a8ff);
+  box-shadow: 0 8px 20px rgba(24, 119, 242, 0.22);
+  font-size: 20px;
+}
+
+.rule-dialog .rule-dialog-tabs {
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 8px;
+  margin-bottom: 14px;
+  padding: 4px 0 10px;
+  border-bottom: 1px dashed #e1e7ef;
+  overflow-x: auto;
+}
+
+.rule-dialog .tab-chip {
+  height: 32px;
+  min-width: auto;
+  padding: 0 16px;
+  border: 1px solid #cdd5df;
+  border-radius: 16px;
+  background: #f4f6fa;
+  color: #5a6573;
+  font-size: 13px;
+  cursor: pointer;
+  transition: all .15s;
+  white-space: nowrap;
+  line-height: 30px;
+  outline: none;
+  margin: 0;
+  font-family: inherit
+}
+
+.rule-dialog .tab-chip:hover {
+  color: #1b8eb1;
+  border-color: #1b8eb1;
+  background: #fff
+}
+
+.rule-dialog .tab-chip.active {
+  color: #fff;
+  background: #1b8eb1;
+  border-color: #1b8eb1;
+  font-weight: 600;
+  box-shadow: 0 2px 6px rgba(27, 142, 177, .25)
+}
+
+.rule-dialog .tab-chip.active:hover {
+  background: #157a99;
+  border-color: #157a99
+}
+
+.rule-dialog .rule-dialog-body {
+  display: flex;
+  gap: 18px;
+  min-height: 380px
+}
+
+.rule-dialog .rule-form-col {
+  flex: 1;
+  min-width: 0
+}
+
+.rule-dialog .rule-side-col {
+  width: 240px;
+  flex: none
+}
+
+.rule-dialog .tab-panel .form-section {
+  padding: 12px;
+  border: 1px solid #e1e7ef;
+  border-radius: 8px;
+  background: #fff;
+  margin-bottom: 12px
+}
+
+.rule-dialog .tab-panel .form-section:last-child {
+  margin-bottom: 0
+}
+
+.rule-dialog .tab-panel .form-section header {
+  margin-bottom: 10px
+}
+
+.rule-dialog .tab-panel .form-section header h3 {
+  margin: 0;
+  font-size: 14px;
+  color: #24344b
+}
+
+.rule-dialog .tab-panel .form-section header p {
+  margin: 4px 0 0;
+  color: #8a96a7;
+  font-size: 11px
+}
+
+.rule-dialog .tab-panel .required {
+  color: #d55752;
+  margin-right: 2px;
+  font-weight: 700
+}
+
+.rule-dialog .tab-panel .form-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0 16px
+}
+
+.rule-dialog .tab-panel .form-grid--three {
+  grid-template-columns: repeat(3, 1fr)
+}
+
+.rule-dialog .tab-panel .el-form-item {
+  margin-bottom: 14px
+}
+
+.rule-dialog .tab-panel .el-form-item__label {
+  padding-bottom: 4px;
+  color: #4a586d;
+  font-size: 11px;
+  line-height: 20px
+}
+
+.rule-dialog .tab-panel .el-form-item .el-input,
+.rule-dialog .tab-panel .el-form-item .el-select,
+.rule-dialog .tab-panel .el-form-item .el-time-picker {
+  width: 100%
+}
+
+.rule-dialog .tab-panel .el-form-item .el-radio {
+  color: #3d4a5e;
+  font-size: 12px;
+  margin-right: 18px
+}
+
+.rule-dialog .tab-panel .el-form-item .el-checkbox {
+  color: #3d4a5e;
+  font-size: 12px;
+  margin-right: 14px
+}
+
+.rule-dialog .tab-panel .method-radio {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 6px 0
+}
+
+.rule-dialog .tab-panel .method-radio .el-radio {
+  display: flex;
+  align-items: flex-start;
+  margin-right: 0;
+  line-height: 1.5
+}
+
+.rule-dialog .tab-panel .method-radio .el-radio__label {
+  color: #3d4a5e;
+  font-size: 12px;
+  line-height: 1.6
+}
+
+.rule-dialog .tab-panel .location-table {
+  margin: 10px 0;
+  border: 1px solid #e1e7ef;
+  border-radius: 6px
+}
+
+.rule-dialog .tab-panel .location-table .el-table__header th.el-table__cell {
+  background: #f6f8fb;
+  color: #68778c;
+  font-weight: 600;
+  height: 36px;
+  padding: 4px 0
+}
+
+.rule-dialog .tab-panel .location-table .el-table__body td.el-table__cell {
+  padding: 6px 0;
+  font-size: 12px
+}
+
+.rule-dialog .tab-panel .range-form-item {
+  margin-top: 10px
+}
+
+.rule-dialog .tab-panel .wifi-tip {
+  margin-top: 14px;
+  padding: 12px 14px;
+  background: #f8faff;
+  border: 1px solid #dfe7f0;
+  border-radius: 6px
+}
+
+.rule-dialog .tab-panel .wifi-tip h4 {
+  margin: 0 0 8px;
+  font-size: 12px;
+  color: #24344b
+}
+
+.rule-dialog .tab-panel .wifi-tip ol {
+  margin: 0;
+  padding-left: 18px;
+  color: #6e7d91;
+  font-size: 11px;
+  line-height: 1.8
+}
+
+.rule-dialog .side-card {
+  padding: 16px;
+  border: 1px solid #e1e7ef;
+  border-radius: 8px;
+  background: linear-gradient(180deg, #f8faff 0%, #fff 100%);
+  position: sticky;
+  top: 18px
+}
+
+.rule-dialog .side-card h4 {
+  margin: 0 0 8px;
+  font-size: 14px;
+  color: #24344b
+}
+
+.rule-dialog .side-card p {
+  margin: 0 0 8px;
+  color: #6e7d91;
+  font-size: 12px;
+  line-height: 1.6
+}
+
+.rule-dialog .side-card .side-desc {
+  color: #8995a5;
+  font-size: 11px;
+  margin-bottom: 14px
+}
+
+.rule-dialog .form-hint {
+  margin-left: 8px;
+  color: #8d99a9;
+  font-size: 11px
+}
+
+/* ===== drawer(el-drawer custom-class="rule-drawer")===== */
+.rule-drawer .employee-summary {
+  display: flex;
+  align-items: center;
+  gap: 11px;
+  padding: 14px;
+  border: 1px solid #dfe7f0;
+  border-radius: 10px;
+  background: #f8faff;
+  margin-bottom: 18px
+}
+
+.rule-drawer .employee-summary > span {
+  width: 46px;
+  height: 46px;
+  display: grid;
+  place-items: center;
+  border-radius: 50%;
+  color: #1768e5;
+  background: #e6efff;
+  font-size: 18px
+}
+
+.rule-drawer .employee-summary h3 {
+  margin: 0;
+  font-size: 15px
+}
+
+.rule-drawer .employee-summary p {
+  margin: 5px 0 0;
+  color: #8390a2;
+  font-size: 11px
+}
+
+.rule-drawer .employee-summary > div:nth-child(3) {
+  margin-left: auto;
+  text-align: right
+}
+
+.rule-drawer .employee-summary > div:nth-child(3) small,
+.rule-drawer .employee-summary > div:nth-child(3) strong {
+  display: block
+}
+
+.rule-drawer .employee-summary > div:nth-child(3) small {
+  color: #8995a5;
+  font-size: 10px
+}
+
+.rule-drawer .employee-summary > div:nth-child(3) strong {
+  margin-top: 3px;
+  color: #1768e5;
+  font-size: 13px
+}
+
+/* ===== 上下班时间 Tab 附加样式 ===== */
+.rule-dialog .tab-panel .week-chip-wrap {
+  display: flex;
+  gap: 8px
+}
+
+.rule-dialog .tab-panel .week-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px
+}
+
+.rule-dialog .tab-panel .week-chips .el-checkbox-button {
+  width: auto;
+  margin: 0 !important
+}
+
+.rule-dialog .tab-panel .week-chips .el-checkbox-button__inner {
+  display: inline-block;
+  padding: 6px 18px;
+  border: 1px solid #d3dbe5 !important;
+  border-radius: 999px !important;
+  background: #fff;
+  color: #5a6573;
+  font-size: 13px;
+  line-height: 18px;
+  box-shadow: none !important;
+  transition: all .15s
+}
+
+.rule-dialog .tab-panel .week-chips .el-checkbox-button:hover .el-checkbox-button__inner {
+  border-color: #1b8eb1;
+  color: #1b8eb1
+}
+
+.rule-dialog .tab-panel .week-chips .el-checkbox-button.is-checked .el-checkbox-button__inner {
+  background: #1b8eb1 !important;
+  border-color: #1b8eb1 !important;
+  color: #fff !important;
+  box-shadow: 0 2px 6px rgba(27, 142, 177, .25) !important
+}
+
+.rule-dialog .tab-panel .week-chips .el-checkbox-button.is-checked .el-checkbox-button__inner:hover {
+  background: #157a99 !important
+}
+
+.rule-dialog .tab-panel .lunch-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px
+}
+
+.rule-dialog .tab-panel .lunch-row .dash {
+  color: #8d99a9;
+  font-size: 12px
+}
+
+.rule-dialog .tab-panel .field-hint {
+  margin-left: 8px;
+  color: #8d99a9;
+  font-size: 11px
+}
+.rule-dialog .tab-panel .time-pair {
+  display: flex;
+  align-items: center;
+  gap: 6px
+}
+.rule-dialog .tab-panel .field-with-dropdown {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%
+}
+
+.rule-dialog .tab-panel .field-with-dropdown .time-pick {
+  flex: 1;
+  min-width: 0
+}
+
+.rule-dialog .tab-panel .field-with-dropdown .open-label {
+  flex: none;
+  color: #6e7d91;
+  font-size: 12px;
+  white-space: nowrap
+}
+
+.rule-dialog .tab-panel .field-with-dropdown .open-select {
+  flex: none;
+  width: 120px
+}
+/* ===== 外出打卡 Tab 附加样式 ===== */
+.rule-dialog .tab-panel .outside-mode {
+  display: flex;
+  flex-direction: column;
+  gap: 8px
+}
+
+.rule-dialog .tab-panel .outside-mode .el-radio {
+  margin-right: 0;
+  display: flex;
+  align-items: flex-start;
+  line-height: 1.4
+}
+
+.rule-dialog .tab-panel .outside-subfield {
+  margin: 4px 0 14px 22px;
+  display: flex;
+  align-items: center;
+  gap: 8px
+}
+
+.rule-dialog .tab-panel .outside-subfield--row {
+  flex-wrap: wrap
+}
+
+.rule-dialog .tab-panel .outside-subfield-label {
+  color: #5a6573;
+  font-size: 12px;
+  white-space: nowrap
+}
+
+.rule-dialog .tab-panel .outside-photo-label {
+  color: #5a6573;
+  font-size: 12px;
+  white-space: nowrap;
+  margin-left: 8px
+}
+.rule-dialog .tab-panel .assistant-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0 16px
+}
+/* ===== 补卡设置 Tab 附加样式 ===== */
+.rule-dialog .tab-panel .makeup-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0 16px;
+  align-items: start
+}
+/* ===== 加班规则 Tab 附加样式 ===== */
+.rule-dialog .tab-panel .overtime-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  gap: 0 16px
+}
+
+.rule-dialog .tab-panel .overtime-period-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap
+}
+
+.rule-dialog .tab-panel .overtime-period-row .el-form-item {
+  margin-bottom: 14px
+}
+
+.rule-dialog .tab-panel .overtime-toggle-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0 16px
+}
+
+.rule-dialog .tab-panel .overtime-section-subtitle {
+  font-size: 12px;
+  color: #4a586d;
+  font-weight: 600;
+  margin: 4px 0 8px;
+  padding-left: 8px;
+  border-left: 2px solid #1b8eb1
+}
+
+.rule-dialog .tab-panel .period-label {
+  color: #5a6573;
+  font-size: 12px;
+  white-space: nowrap
+}
+
+
+/* ===== 请假规则 Tab 附加样式 ===== */
+.rule-dialog .tab-panel .leave-rule-table {
+  border-radius: 8px;
+  overflow: hidden;
+  margin-bottom: 12px
+}
+
+.rule-dialog .tab-panel .leave-rule-table .el-table__header th.el-table__cell {
+  background: #f6f8fb;
+  color: #2b3a52;
+  font-weight: 600
+}
+
+.rule-dialog .tab-panel .leave-rule-table .status-pill {
+  font-size: 11px
+}
+
+.rule-dialog .tab-panel .leave-rule-table .el-button.is-archived {
+  color: #aab4c0;
+  cursor: default
+}
+
+.rule-dialog .tab-panel .leave-rule-table .el-button.is-archived:hover {
+  color: #aab4c0
+}
+
+.rule-dialog .tab-panel .leave-rule-actions {
+  text-align: right;
+  margin-top: 8px
+}
+
+.rule-dialog .tab-panel .status-pill.pending {
+  color: #c67d23
+}
+
+.rule-dialog .tab-panel .status-pill.pending i {
+  background: #e6a23c
+}
+
+
+
+
+
+.rule-dialog .tab-panel .time-pair .dash {
+  color: #8d99a9;
+  font-size: 12px;
+  flex: none
+}
+
+/* ===== 工作时间(多条)样式 ===== */
+.rule-dialog .tab-panel .shift-card {
+  padding: 12px 14px;
+  border: 1px dashed #cdd5df;
+  border-radius: 8px;
+  background: #fbfcfe;
+  margin-bottom: 12px
+}
+
+.rule-dialog .tab-panel .shift-card:first-of-type {
+  margin-top: 0
+}
+
+.rule-dialog .tab-panel .shift-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px
+}
+
+.rule-dialog .tab-panel .rule-dialog .tab-panel .shift-no {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  color: #2b3a52;
+  font-size: 13px;
+  font-weight: 600
+}
+
+.rule-dialog .tab-panel .shift-no i {
+  display: grid;
+  place-items: center;
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #1b8eb1, #1768e5);
+  color: #fff;
+  font-size: 12px;
+  font-style: normal;
+  font-weight: 700;
+  box-shadow: 0 2px 5px rgba(23, 104, 229, .25)
+}
+
+.rule-dialog .tab-panel .lunch-block {
+  margin-top: 6px;
+  padding-top: 10px;
+  border-top: 1px dashed #e2e7ee
+}
+
+.rule-dialog .tab-panel .lunch-title {
+  font-size: 12px;
+  color: #4a586d;
+  margin-bottom: 8px
+}
+
+.rule-dialog .tab-panel .add-shift-btn {
+  width: 100%;
+  border-style: dashed !important;
+  color: #1768e5;
+  font-size: 13px;
+  letter-spacing: 1px
+}
+
+/* ===== 考勤设备 Tab 附加样式 ===== */
+.rule-dialog .tab-panel .device-table {
+  margin-top: 10px
+}
+
+.rule-dialog .tab-panel .device-table .el-table__header th.el-table__cell {
+  background: #f6f8fb;
+  color: #68778c;
+  font-weight: 600
+}
+
+.rule-dialog .tab-panel .device-table .el-button {
+  padding: 0 4px;
+  font-size: 12px
+}
+
+.rule-dialog .tab-panel .status-dot {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 12px
+}
+
+.rule-dialog .tab-panel .status-dot i {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  display: inline-block
+}
+
+.rule-dialog .tab-panel .status-dot.on {
+  color: #1ea760
+}
+
+.rule-dialog .tab-panel .status-dot.on i {
+  background: #1ea760;
+  box-shadow: 0 0 0 3px rgba(30, 167, 96, .18);
+  animation: pulse-dot 1.6s ease-in-out infinite
+}
+
+.rule-dialog .tab-panel .status-dot.off {
+  color: #aab4c0
+}
+
+.rule-dialog .tab-panel .status-dot.off i {
+  background: #c0c8d2
+}
+
+@keyframes pulse-dot {
+  0%, 100% { box-shadow: 0 0 0 3px rgba(30, 167, 96, .18) }
+  50% { box-shadow: 0 0 0 6px rgba(30, 167, 96, 0) }
+}
+
+/* ===== 设备详情 / 日志查看弹窗 ===== */
+.device-view {
+  font-size: 13px;
+  color: #2b3a52
+}
+
+.device-view .view-row {
+  display: flex;
+  align-items: center;
+  padding: 8px 0;
+  border-bottom: 1px dashed #edf0f5
+}
+
+.device-view .view-row:last-child {
+  border-bottom: 0
+}
+
+.device-view .view-row span {
+  flex: none;
+  width: 110px;
+  color: #8390a5;
+  font-size: 12px
+}
+
+.device-view .view-row strong {
+  flex: 1;
+  color: #24344b;
+  font-weight: 500;
+  word-break: break-all
+}
+
+.device-view .device-log-list {
+  max-height: 420px;
+  overflow-y: auto
+}
+
+.device-view .log-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 0;
+  border-bottom: 1px dashed #edf0f5;
+  font-size: 12px
+}
+
+.device-view .log-item:last-child {
+  border-bottom: 0
+}
+
+.device-view .log-time {
+  flex: none;
+  width: 150px;
+  color: #8995a5;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
+}
+
+.device-view .log-tag {
+  flex: none;
+  display: inline-block;
+  padding: 1px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  font-weight: 500
+}
+
+.device-view .log-tag.success {
+  color: #1ea760;
+  background: #e8f7ef
+}
+
+.device-view .log-tag.info {
+  color: #1768e5;
+  background: #e6efff
+}
+
+.device-view .log-tag.warn {
+  color: #c67d23;
+  background: #fdf3e3
+}
+
+.device-view .log-tag.error {
+  color: #d55752;
+  background: #fbe9e8
+}
+
+.device-view .log-msg {
+  flex: 1;
+  color: #2b3a52
+}
+</style>
+<style src="../../../styles/views/attendance/index.scss" lang="scss" scoped>
+.rule-page {
+  padding: 18px
+}
+
+.rule-card {
+  border-radius: 8px
+}
+
+.rule-card .filter-bar--rule {
+  grid-template-columns: 1.5fr 1fr 1fr 1fr auto;
+  background: #fff;
+  border-bottom: 1px solid #edf1f6
+}
+
+.rule-card .filter-bar {
+  padding: 14px 18px
+}
+
+.rule-card .table-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 14px 18px;
+  border-bottom: 1px solid #edf1f6;
+  background: #fbfcfe
+}
+
+.rule-card .table-toolbar span {
+  color: #6e7d91;
+  font-size: 12px
+}
+
+.rule-card .table-toolbar b {
+  color: #1768e5;
+  font-size: 14px;
+  margin: 0 3px
+}
+
+.rule-table {
+  padding: 0 18px 18px
+}
+
+.rule-table ::v-deep th.el-table__cell {
+  height: 46px;
+  padding: 0 6px;
+  color: #68778c;
+  background: #f6f8fb;
+  font-weight: 600;
+  font-size: 12px
+}
+
+.rule-table ::v-deep td.el-table__cell {
+  padding: 8px 6px;
+  border-bottom-color: #edf0f3;
+  font-size: 12px
+}
+
+.rule-table ::v-deep .el-table__row:hover > td.el-table__cell {
+  background: #f2f7ff !important
+}
+
+.rule-page .name-cell strong {
+  display: block;
+  color: #24344b;
+  font-size: 13px;
+  font-weight: 500
+}
+
+.rule-page .name-cell small {
+  display: block;
+  margin-top: 3px;
+  color: #8d99a9;
+  font-size: 10px
+}
+
+.rule-page .method-cell {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap
+}
+
+.rule-page .cell-multiline {
+  display: block;
+  color: #6e7d91;
+  font-size: 11px;
+  line-height: 1.5;
+  max-width: 240px
+}
+</style>