Commit 8429e4b4ae701e45b3226b13e3e2ed1f03041e24

Authored by “wangming”
1 parent ed7c85aa

Enhance attendance record and punch application features by introducing a new vi…

…ew mode for weekly and monthly attendance, updating UI components for better user experience, and implementing Wi-Fi and geofencing options for attendance verification. Refactor backend services to support new attendance data structures and improve query efficiency for eligible users.
Showing 20 changed files with 2362 additions and 944 deletions
antis-ncc-admin/src/views/attendance-record/components/record-detail-dialog.vue
... ... @@ -44,7 +44,7 @@
44 44 </div>
45 45 <div class="overview-card info">
46 46 <div class="overview-card__label">录入方式</div>
47   - <div class="overview-card__value">{{ detail.isManual === 1 ? '含补卡/手工修正' : '真实打卡' }}</div>
  47 + <div class="overview-card__value">{{ isNoAttendanceRecord ? '无' : (detail.isManual === 1 ? '含补卡/手工修正' : '真实打卡') }}</div>
48 48 </div>
49 49 </div>
50 50  
... ... @@ -54,7 +54,7 @@
54 54 <span>上班打卡</span>
55 55 <div class="punch-card__actions">
56 56 <el-tag size="mini" :type="detail.punchIn && detail.punchIn.type === 2 ? 'warning' : 'success'">
57   - {{ detail.punchIn ? detail.punchIn.typeText || '未打卡' : '未打卡' }}
  57 + {{ punchInTypeLabel }}
58 58 </el-tag>
59 59 <el-button size="mini" type="text" :disabled="!hasCoordinate(detail.punchIn)"
60 60 @click="openMap(detail.punchIn, '上班打卡地点')">查看地图</el-button>
... ... @@ -63,7 +63,7 @@
63 63 <div class="punch-card__content">
64 64 <div class="field-item">
65 65 <label>打卡时间</label>
66   - <span>{{ detail.punchIn ? detail.punchIn.time || '未打卡' : '未打卡' }}</span>
  66 + <span>{{ punchInTimeLabel }}</span>
67 67 </div>
68 68 <div class="field-item">
69 69 <label>打卡地址</label>
... ... @@ -89,7 +89,7 @@
89 89 <span>下班打卡</span>
90 90 <div class="punch-card__actions">
91 91 <el-tag size="mini" :type="detail.punchOut && detail.punchOut.type === 2 ? 'warning' : 'success'">
92   - {{ detail.punchOut ? detail.punchOut.typeText || '未打卡' : '未打卡' }}
  92 + {{ punchOutTypeLabel }}
93 93 </el-tag>
94 94 <el-button size="mini" type="text" :disabled="!hasCoordinate(detail.punchOut)"
95 95 @click="openMap(detail.punchOut, '下班打卡地点')">查看地图</el-button>
... ... @@ -98,7 +98,7 @@
98 98 <div class="punch-card__content">
99 99 <div class="field-item">
100 100 <label>打卡时间</label>
101   - <span>{{ detail.punchOut ? detail.punchOut.time || '未打卡' : '未打卡' }}</span>
  101 + <span>{{ punchOutTimeLabel }}</span>
102 102 </div>
103 103 <div class="field-item">
104 104 <label>打卡地址</label>
... ... @@ -120,7 +120,7 @@
120 120 </div>
121 121 </div>
122 122  
123   - <el-card shadow="never" class="supplement-card">
  123 + <el-card v-if="hasSupplementInfo" shadow="never" class="supplement-card">
124 124 <div slot="header" class="supplement-card__header supplement-card__header--row">
125 125 <span>补卡信息</span>
126 126 <el-button v-if="canShowCancelWorkflowSupplement" type="danger" size="mini" plain
... ... @@ -151,7 +151,7 @@
151 151 </div>
152 152 </el-card>
153 153  
154   - <el-card v-if="hasRelatedWorkflows || isLeaveOrSickStatus" shadow="never"
  154 + <el-card v-if="hasAttendanceRecord && (hasRelatedWorkflows || isLeaveOrSickStatus)" shadow="never"
155 155 class="supplement-card workflow-card">
156 156 <div slot="header" class="supplement-card__header">
157 157 <span>关联流程</span>
... ... @@ -176,6 +176,17 @@
176 176 <span>当前为{{ detail.statusText }}状态,暂无关联流程记录</span>
177 177 </div>
178 178 </el-card>
  179 +
  180 + <el-card v-if="showRecordRemarkOnly" shadow="never" class="supplement-card">
  181 + <div slot="header" class="supplement-card__header">
  182 + <span>记录备注</span>
  183 + </div>
  184 + <div class="supplement-grid">
  185 + <div class="field-item field-item--full">
  186 + <span>{{ detail.remark || '无' }}</span>
  187 + </div>
  188 + </div>
  189 + </el-card>
179 190 </template>
180 191  
181 192 <el-empty v-else :image-size="72" description="暂无考勤详情" />
... ... @@ -232,6 +243,7 @@ export default {
232 243 case 2:
233 244 return 'warning'
234 245 case 3:
  246 + case 8:
235 247 return 'info'
236 248 default:
237 249 return 'danger'
... ... @@ -240,12 +252,78 @@ export default {
240 252 hasRelatedWorkflows() {
241 253 return this.detail && Array.isArray(this.detail.relatedWorkflows) && this.detail.relatedWorkflows.length > 0
242 254 },
  255 + /** 后端无考勤行时返回 hasAttendanceRecord: false(兼容旧接口:有 id 视为有记录) */
  256 + hasAttendanceRecord() {
  257 + const d = this.detail
  258 + if (!d) return true
  259 + const h = d.hasAttendanceRecord
  260 + if (h === false || h === 0) return false
  261 + if (d.id) return true
  262 + return h !== false && h !== 0
  263 + },
  264 + isNoAttendanceRecord() {
  265 + return !!(this.detail && (this.detail.hasAttendanceRecord === false || this.detail.hasAttendanceRecord === 0))
  266 + },
  267 + /** 考勤日晚于今日、尚无打卡(后端 status=8 待考勤) */
  268 + isPendingAttendanceDay() {
  269 + const d = this.detail
  270 + if (!d || !this.hasAttendanceRecord) return false
  271 + const s = d.status
  272 + return s === 8 || s === '8'
  273 + },
  274 + /** 备注已记销假时不再视为「待销假」请假态,避免仍显示确认销假 */
243 275 isLeaveOrSickStatus() {
244   - return this.detail && (this.detail.status === 4 || this.detail.status === 5)
  276 + if (!this.detail) return false
  277 + const r = this.detail.remark
  278 + if (r != null && String(r).indexOf('销假') >= 0) return false
  279 + return this.detail.status === 4 || this.detail.status === 5
  280 + },
  281 + /** 无补卡卡片时,若有考勤备注仍单独展示(原备注在补卡卡片内) */
  282 + showRecordRemarkOnly() {
  283 + if (!this.detail || !this.hasAttendanceRecord) return false
  284 + const r = this.detail.remark
  285 + if (r == null || String(r).trim() === '') return false
  286 + return !this.hasSupplementInfo
  287 + },
  288 + /** 无流程补卡数据时不展示补卡信息卡片(避免全是「无」仍占版面) */
  289 + hasSupplementInfo() {
  290 + if (!this.detail || !this.hasAttendanceRecord) return false
  291 + const s = this.detail.supplement
  292 + if (!s) return false
  293 + const wf = s.workflowId != null && s.workflowId !== '' ? s.workflowId : s.WorkflowId
  294 + if (wf) return true
  295 + const r = s.remark != null && s.remark !== '' ? s.remark : s.Remark
  296 + if (r) return true
  297 + const op = s.operatorName != null && s.operatorName !== '' ? s.operatorName : s.OperatorName
  298 + if (op) return true
  299 + const t = s.operateTime != null && s.operateTime !== '' ? s.operateTime : s.OperateTime
  300 + return !!t
  301 + },
  302 + punchInTypeLabel() {
  303 + if (this.isNoAttendanceRecord || this.isPendingAttendanceDay) return '暂无打卡'
  304 + const p = this.detail && this.detail.punchIn
  305 + return p ? (p.typeText || '未打卡') : '未打卡'
  306 + },
  307 + punchInTimeLabel() {
  308 + if (this.isNoAttendanceRecord || this.isPendingAttendanceDay) return '暂无打卡'
  309 + const p = this.detail && this.detail.punchIn
  310 + return p ? (p.time || '未打卡') : '未打卡'
  311 + },
  312 + punchOutTypeLabel() {
  313 + if (this.isNoAttendanceRecord || this.isPendingAttendanceDay) return '暂无打卡'
  314 + const p = this.detail && this.detail.punchOut
  315 + return p ? (p.typeText || '未打卡') : '未打卡'
  316 + },
  317 + punchOutTimeLabel() {
  318 + if (this.isNoAttendanceRecord || this.isPendingAttendanceDay) return '暂无打卡'
  319 + const p = this.detail && this.detail.punchOut
  320 + return p ? (p.time || '未打卡') : '未打卡'
245 321 },
246 322 /** 正常上下班均已打卡,或已有补卡/手工修正,或请假/病假:不显示后台补卡入口 */
247 323 showSupplementButton() {
248 324 if (!this.detail) return false
  325 + if (!this.detail.id) return false
  326 + if (this.detail.status === 8 || this.detail.status === '8') return false
249 327 if (this.detail.isManual === 1) return false
250 328 if (this.detail.status === 4 || this.detail.status === 5) return false
251 329 const hasIn = !!(this.detail.punchIn && this.detail.punchIn.time)
... ... @@ -311,6 +389,7 @@ export default {
311 389 workflowTagType(type) {
312 390 if (type === '请假') return 'danger'
313 391 if (type === '补卡') return 'warning'
  392 + if (type === '销假') return 'success'
314 393 return 'info'
315 394 },
316 395 canOpenRelatedWorkflow(wf) {
... ...
antis-ncc-admin/src/views/attendance-record/index.vue
... ... @@ -7,11 +7,11 @@
7 7 <div class="record-hero__eyebrow">Attendance Record</div>
8 8 <h2 class="record-hero__title">考勤记录</h2>
9 9 <p class="record-hero__desc">
10   - 按月查看员工每日考勤状态(含打卡、补卡及审批通过的请假/病假同步),可查看上下班时间、地点、照片与补卡信息;点击有记录的日期可查看详情并后台补卡。
  10 + 按月查看员工每日考勤状态(列表含筛选范围内全部在职员工,无记录日期显示为无);含打卡、补卡及审批通过的请假/病假同步,可查看上下班时间、地点、照片与补卡信息;点击日期可查看详情并后台补卡。
11 11 </p>
12 12 </div>
13 13 <div class="record-hero__actions">
14   - <el-button type="primary" plain icon="el-icon-download" :loading="exportLoading"
  14 + <el-button v-if="viewMode === 'month'" type="primary" plain icon="el-icon-download" :loading="exportLoading"
15 15 @click="exportMonthReport">导出月度明细</el-button>
16 16 <el-button icon="el-icon-refresh-right" @click="refreshPage">刷新</el-button>
17 17 </div>
... ... @@ -22,10 +22,21 @@
22 22  
23 23 <el-card class="filter-card" shadow="never">
24 24 <el-form :inline="true" :model="query" class="filter-form" @submit.native.prevent>
25   - <el-form-item label="统计月份">
  25 + <el-form-item label="视图">
  26 + <el-radio-group v-model="viewMode" size="mini" @change="onViewModeChange">
  27 + <el-radio-button label="month">月</el-radio-button>
  28 + <el-radio-button label="week">周</el-radio-button>
  29 + </el-radio-group>
  30 + </el-form-item>
  31 +
  32 + <el-form-item v-if="viewMode === 'month'" label="统计月份">
26 33 <el-date-picker v-model="query.month" type="month" value-format="yyyy-MM" format="yyyy-MM"
27 34 placeholder="选择月份" />
28 35 </el-form-item>
  36 + <el-form-item v-else label="选择日期">
  37 + <el-date-picker v-model="query.weekDate" type="date" value-format="yyyy-MM-dd" format="yyyy-MM-dd"
  38 + placeholder="选择任意日期" @change="onWeekDateChange" />
  39 + </el-form-item>
29 40 <el-form-item label="考勤分组">
30 41 <el-select v-model="query.attendanceGroupId" clearable filterable placeholder="全部分组">
31 42 <el-option v-for="item in attendanceGroupOptions" :key="item.id" :label="item.fullName"
... ... @@ -46,7 +57,7 @@
46 57 <div class="summary-card">
47 58 <div class="summary-card__label">员工人数</div>
48 59 <div class="summary-card__value">{{ summary.employeeCount }}</div>
49   - <div class="summary-card__hint">当前筛选条件下有考勤记录的员工数</div>
  60 + <div class="summary-card__hint">当前筛选条件下的在职员工数(含当月无考勤记录)</div>
50 61 </div>
51 62 <div class="summary-card success">
52 63 <div class="summary-card__label">正常出勤</div>
... ... @@ -68,7 +79,7 @@
68 79 <el-card class="table-card" shadow="never">
69 80 <div slot="header" class="table-card__header">
70 81 <div>
71   - <span>月度考勤明细</span>
  82 + <span>{{ viewMode === 'month' ? '月度考勤明细' : '周度考勤明细' }}</span>
72 83 <span class="table-card__sub">{{ headerText }}</span>
73 84 </div>
74 85 <div class="table-card__legend">
... ... @@ -94,10 +105,10 @@
94 105 </template>
95 106 </el-table-column>
96 107  
97   - <el-table-column v-for="day in monthDays" :key="day.key" :label="day.label" :min-width="110" align="left">
  108 + <el-table-column v-for="day in activeDays" :key="day.key" :label="day.label" :min-width="110" align="left">
98 109 <template slot-scope="scope">
99 110 <div class="day-cell"
100   - :class="[`status-${scope.row.dayRecords[day.key].statusKey}`, { clickable: scope.row.dayRecords[day.key].statusKey !== 'empty' }]"
  111 + :class="[`status-${scope.row.dayRecords[day.key].statusKey}`, 'clickable']"
101 112 @click="handleOpenDetail(scope.row, day.key, scope.row.dayRecords[day.key])">
102 113 <div class="day-cell__time">{{ scope.row.dayRecords[day.key].timeText }}</div>
103 114 <el-tag :type="scope.row.dayRecords[day.key].tagType" size="mini" effect="plain">
... ... @@ -136,6 +147,7 @@ export default {
136 147 },
137 148 data() {
138 149 return {
  150 + viewMode: 'month',
139 151 pageLoading: false,
140 152 tableLoading: false,
141 153 exportLoading: false,
... ... @@ -147,7 +159,29 @@ export default {
147 159 }
148 160 },
149 161 computed: {
150   - monthDays() {
  162 + headerText() {
  163 + if (this.viewMode === 'week') {
  164 + const anchor = dayjs(this.query.weekDate || dayjs().format('YYYY-MM-DD'))
  165 + const diff = (anchor.day() + 6) % 7 // Monday-based
  166 + const start = anchor.subtract(diff, 'day')
  167 + const end = start.add(6, 'day')
  168 + return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`
  169 + }
  170 + return `${this.query.month} · 共 ${this.activeDays.length} 天`
  171 + },
  172 + activeDays() {
  173 + if (this.viewMode === 'week') {
  174 + const anchor = dayjs(this.query.weekDate || dayjs().format('YYYY-MM-DD'))
  175 + const diff = (anchor.day() + 6) % 7 // Monday-based
  176 + const start = anchor.subtract(diff, 'day')
  177 + return Array.from({ length: 7 }).map((_, index) => {
  178 + const date = start.add(index, 'day')
  179 + return {
  180 + key: date.format('YYYY-MM-DD'),
  181 + label: `${index + 1}日\n${this.getWeekLabel(date.day())}`
  182 + }
  183 + })
  184 + }
151 185 const base = dayjs(`${this.query.month}-01`)
152 186 const total = base.daysInMonth()
153 187 return Array.from({ length: total }).map((_, index) => {
... ... @@ -157,9 +191,6 @@ export default {
157 191 label: `${index + 1}日\n${this.getWeekLabel(date.day())}`
158 192 }
159 193 })
160   - },
161   - headerText() {
162   - return `${this.query.month} · 共 ${this.monthDays.length} 天`
163 194 }
164 195 },
165 196 created() {
... ... @@ -169,6 +200,7 @@ export default {
169 200 createDefaultQuery() {
170 201 return {
171 202 month: dayjs().format('YYYY-MM'),
  203 + weekDate: dayjs().format('YYYY-MM-DD'),
172 204 attendanceGroupId: '',
173 205 keyword: '',
174 206 currentPage: 1,
... ... @@ -228,6 +260,9 @@ export default {
228 260 }
229 261 },
230 262 async loadData() {
  263 + if (this.viewMode === 'week') {
  264 + this.query.month = dayjs(this.query.weekDate || dayjs().format('YYYY-MM-DD')).format('YYYY-MM')
  265 + }
231 266 if (!this.query.month) {
232 267 this.summary = this.createEmptySummary()
233 268 this.total = 0
... ... @@ -252,7 +287,7 @@ export default {
252 287 },
253 288 normalizeRow(item) {
254 289 const dayRecordMap = {}
255   - this.monthDays.forEach(day => {
  290 + this.activeDays.forEach(day => {
256 291 dayRecordMap[day.key] = this.createEmptyDayRecord()
257 292 })
258 293 ; (item.dayRecords || []).forEach(record => {
... ... @@ -271,10 +306,22 @@ export default {
271 306 this.loadData()
272 307 },
273 308 search() {
274   - if (!this.query.month) {
  309 + if (this.viewMode === 'month' && !this.query.month) {
275 310 this.$message.warning('请选择统计月份')
276 311 return
277 312 }
  313 + if (this.viewMode === 'week' && !this.query.weekDate) {
  314 + this.$message.warning('请选择日期')
  315 + return
  316 + }
  317 + this.query.currentPage = 1
  318 + this.loadData()
  319 + },
  320 + onViewModeChange() {
  321 + this.query.currentPage = 1
  322 + this.loadData()
  323 + },
  324 + onWeekDateChange() {
278 325 this.query.currentPage = 1
279 326 this.loadData()
280 327 },
... ... @@ -283,7 +330,7 @@ export default {
283 330 this.loadData()
284 331 },
285 332 handleOpenDetail(row, attendanceDate, dayRecord) {
286   - if (!dayRecord || dayRecord.statusKey === 'empty') return
  333 + // 即使当天为“空白/无记录”,也允许点开详情(弹窗内会显示暂无详情)
287 334 this.$refs.recordDetailDialog.open({
288 335 userId: row.userId,
289 336 attendanceDate
... ... @@ -504,7 +551,8 @@ export default {
504 551 }
505 552  
506 553 .status-rest .day-cell__time,
507   - .status-empty .day-cell__time {
  554 + .status-empty .day-cell__time,
  555 + .status-pending .day-cell__time {
508 556 color: #909399;
509 557 }
510 558  
... ...
antis-ncc-admin/src/views/lqMdxx/Form.vue
... ... @@ -53,6 +53,62 @@
53 53 </div>
54 54 </el-form-item>
55 55 </el-col>
  56 + <el-col :span="24">
  57 + <el-form-item label="正常打卡方式">
  58 + <div class="att-punch-check-row">
  59 + <el-checkbox :true-label="1" :false-label="0"
  60 + v-model="dataForm.attendanceCheckFence">范围打卡(电子围栏)</el-checkbox>
  61 + <el-checkbox :true-label="1" :false-label="0" v-model="dataForm.attendanceCheckWifi">Wi-Fi
  62 + 打卡</el-checkbox>
  63 + </div>
  64 + <div class="att-punch-hint">
  65 + 可同时勾选:<strong>两者都勾选</strong>时,员工在打卡<strong>范围内</strong>或满足<strong>门店
  66 + Wi-Fi 规则</strong>,<strong>满足其一</strong>即可;仅勾选一项则只校验该项。
  67 + Wi-Fi 以<strong>成对表</strong>维护(SSID + BSSID);可选「校验对应」防同名热点,拿不到 BSSID 时需依赖<strong>围栏
  68 + + SSID</strong>。
  69 + </div>
  70 + </el-form-item>
  71 + </el-col>
  72 + <el-col :span="24" v-if="Number(dataForm.attendanceCheckWifi) === 1">
  73 + <el-form-item label="Wi-Fi 白名单">
  74 + <div v-if="!isDetail" class="wifi-pair-toolbar">
  75 + <el-button type="primary" size="small" icon="el-icon-plus"
  76 + @click="addWifiPair">添加一行</el-button>
  77 + </div>
  78 + <div class="wifi-pair-table">
  79 + <div class="wifi-pair-head">
  80 + <span class="wifi-pair-col-ssid">Wi-Fi 名称 (SSID)</span>
  81 + <span class="wifi-pair-col-bssid">BSSID(路由器 MAC)</span>
  82 + <span v-if="!isDetail" class="wifi-pair-col-act"></span>
  83 + </div>
  84 + <div v-for="(row, index) in dataForm.attendanceWifiPairList" :key="'wp-' + index"
  85 + class="wifi-pair-row">
  86 + <el-input v-model="row.ssid" size="small" :disabled="isDetail" maxlength="64"
  87 + placeholder="与手机显示一致" />
  88 + <el-input v-model="row.bssid" size="small" :disabled="isDetail" maxlength="64"
  89 + placeholder="如 aa:bb:cc:dd:ee:ff" />
  90 + <div v-if="!isDetail" class="wifi-pair-col-act">
  91 + <el-button type="text" size="small" class="wifi-pair-del"
  92 + @click="removeWifiPair(index)">删除</el-button>
  93 + </div>
  94 + </div>
  95 + <div v-if="!dataForm.attendanceWifiPairList.length" class="store-tags-empty">请至少添加一行;建议 SSID
  96 + 与 BSSID 成对填写</div>
  97 + </div>
  98 + </el-form-item>
  99 + </el-col>
  100 + <el-col :span="24" v-if="Number(dataForm.attendanceCheckWifi) === 1">
  101 + <el-form-item label=" ">
  102 + <el-checkbox :true-label="1" :false-label="0" v-model="dataForm.attendanceWifiVerifyPair">
  103 + 校验 SSID 与 BSSID 为同一 AP(防同名热点)
  104 + </el-checkbox>
  105 + <div class="att-punch-hint wifi-verify-hint">
  106 + <strong>开启</strong>:能获取 BSSID 时必须与表中<strong>同一行</strong>的 SSID、BSSID 同时一致;获取不到 BSSID
  107 + 时须<strong>在电子围栏内</strong>且 SSID 命中。<strong>关闭</strong>:当前网络的 SSID 或 BSSID
  108 + 命中表中<strong>任意一行</strong>的任一字段即可。
  109 + </div>
  110 + </el-form-item>
  111 + </el-col>
56 112  
57 113 <!-- 其他业务字段 -->
58 114 <el-col :span="12">
... ... @@ -98,70 +154,43 @@
98 154 </el-col>
99 155 <el-col :span="24">
100 156 <el-form-item label="门店图片" prop="storeImages">
101   - <NCC-UploadImg v-model="dataForm.storeImages" :fileSize="5" sizeUnit="MB" :limit="9" :disabled="!!isDetail" />
  157 + <NCC-UploadImg v-model="dataForm.storeImages" :fileSize="5" sizeUnit="MB" :limit="9"
  158 + :disabled="!!isDetail" />
102 159 </el-form-item>
103 160 </el-col>
104 161 <el-col :span="24">
105 162 <el-form-item label="门店介绍" prop="storeDescription">
106   - <el-input v-model="dataForm.storeDescription" type="textarea" :rows="4" maxlength="1000" show-word-limit
107   - placeholder="请输入门店介绍" />
  163 + <el-input v-model="dataForm.storeDescription" type="textarea" :rows="4" maxlength="1000"
  164 + show-word-limit placeholder="请输入门店介绍" />
108 165 </el-form-item>
109 166 </el-col>
110 167 <el-col :span="24">
111 168 <el-form-item label="营业时间设置" prop="businessHours">
112   - <el-input
113   - v-model="dataForm.businessHours"
114   - type="textarea"
115   - :rows="3"
116   - maxlength="500"
117   - show-word-limit
118   - placeholder="请输入营业时间设置"
119   - />
  169 + <el-input v-model="dataForm.businessHours" type="textarea" :rows="3" maxlength="500"
  170 + show-word-limit placeholder="请输入营业时间设置" />
120 171 </el-form-item>
121 172 </el-col>
122 173 <el-col :span="24">
123 174 <el-form-item label="交通提示" prop="trafficTips">
124   - <el-input
125   - v-model="dataForm.trafficTips"
126   - type="textarea"
127   - :rows="3"
128   - maxlength="500"
129   - show-word-limit
130   - placeholder="请输入交通提示"
131   - />
  175 + <el-input v-model="dataForm.trafficTips" type="textarea" :rows="3" maxlength="500"
  176 + show-word-limit placeholder="请输入交通提示" />
132 177 </el-form-item>
133 178 </el-col>
134 179 <el-col :span="24">
135 180 <el-form-item label="门店标签" prop="storeTags">
136 181 <div class="store-tags-editor">
137 182 <div class="store-tags-list">
138   - <el-tag
139   - v-for="(tag, index) in dataForm.storeTags"
140   - :key="`${tag}-${index}`"
141   - :disable-transitions="false"
142   - :closable="!isDetail"
143   - @close="handleRemoveTag(index)">
  183 + <el-tag v-for="(tag, index) in dataForm.storeTags" :key="`${tag}-${index}`"
  184 + :disable-transitions="false" :closable="!isDetail" @close="handleRemoveTag(index)">
144 185 {{ tag }}
145 186 </el-tag>
146 187 <span v-if="!dataForm.storeTags.length" class="store-tags-empty">暂无标签</span>
147 188 </div>
148 189 <div class="store-tags-action-row" v-if="!isDetail">
149   - <el-input
150   - v-if="inputTagVisible"
151   - ref="tagInput"
152   - v-model="inputTagValue"
153   - class="input-new-tag"
154   - size="small"
155   - maxlength="20"
156   - placeholder="请输入标签内容"
157   - @keyup.enter.native="handleInputTagConfirm"
158   - @blur="handleInputTagConfirm"
159   - />
160   - <el-button
161   - v-else
162   - class="button-new-tag tag-action-btn"
163   - size="small"
164   - icon="el-icon-plus"
  190 + <el-input v-if="inputTagVisible" ref="tagInput" v-model="inputTagValue"
  191 + class="input-new-tag" size="small" maxlength="20" placeholder="请输入标签内容"
  192 + @keyup.enter.native="handleInputTagConfirm" @blur="handleInputTagConfirm" />
  193 + <el-button v-else class="button-new-tag tag-action-btn" size="small" icon="el-icon-plus"
165 194 @click="showTagInput">
166 195 新增标签
167 196 </el-button>
... ... @@ -186,15 +215,10 @@
186 215 </div>
187 216 </div>
188 217 <div class="location-search-bar">
189   - <el-input
190   - v-model="locationSearchKeyword"
191   - placeholder="输入地址或关键词检索(如:成都市武侯区紫荆北路182号)"
192   - clearable
193   - size="small"
194   - class="search-input"
195   - @keyup.enter.native="handleSearchLocation"
196   - >
197   - <el-button slot="append" icon="el-icon-search" @click="handleSearchLocation" :loading="locationSearchLoading">检索</el-button>
  218 + <el-input v-model="locationSearchKeyword" placeholder="输入地址或关键词检索(如:成都市武侯区紫荆北路182号)" clearable
  219 + size="small" class="search-input" @keyup.enter.native="handleSearchLocation">
  220 + <el-button slot="append" icon="el-icon-search" @click="handleSearchLocation"
  221 + :loading="locationSearchLoading">检索</el-button>
198 222 </el-input>
199 223 </div>
200 224 <div id="location-map" class="map-canvas"></div>
... ... @@ -253,22 +277,22 @@ const TMAP_WS_KEY = &#39;YRXBZ-NEV6T-K7SXH-VJPMF-G5IQF-F3FCJ&#39;
253 277  
254 278 // JSONP 调用(绕过 CORS,腾讯 WebService API 前端需用 JSONP)
255 279 function jsonp(url) {
256   - return new Promise((resolve, reject) => {
257   - const cbName = '_tmap_jsonp_' + Date.now() + '_' + Math.random().toString(36).slice(2);
258   - const script = document.createElement('script');
259   - script.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'output=jsonp&callback=' + cbName;
260   - window[cbName] = function(res) {
261   - if (script.parentNode) script.parentNode.removeChild(script);
262   - delete window[cbName];
263   - resolve(res);
264   - };
265   - script.onerror = function() {
266   - if (script.parentNode) script.parentNode.removeChild(script);
267   - delete window[cbName];
268   - reject(new Error('JSONP request failed'));
269   - };
270   - document.body.appendChild(script);
271   - });
  280 + return new Promise((resolve, reject) => {
  281 + const cbName = '_tmap_jsonp_' + Date.now() + '_' + Math.random().toString(36).slice(2);
  282 + const script = document.createElement('script');
  283 + script.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'output=jsonp&callback=' + cbName;
  284 + window[cbName] = function (res) {
  285 + if (script.parentNode) script.parentNode.removeChild(script);
  286 + delete window[cbName];
  287 + resolve(res);
  288 + };
  289 + script.onerror = function () {
  290 + if (script.parentNode) script.parentNode.removeChild(script);
  291 + delete window[cbName];
  292 + reject(new Error('JSONP request failed'));
  293 + };
  294 + document.body.appendChild(script);
  295 + });
272 296 }
273 297  
274 298 export default {
... ... @@ -296,6 +320,10 @@ export default {
296 320 storeTags: [],
297 321 businessHours: '',
298 322 trafficTips: '',
  323 + attendanceCheckFence: 0,
  324 + attendanceCheckWifi: 0,
  325 + attendanceWifiPairList: [],
  326 + attendanceWifiVerifyPair: 0,
299 327 },
300 328 rules: {
301 329 mdbm: [{ required: true, message: '请输入门店编码', trigger: 'blur' }],
... ... @@ -383,6 +411,14 @@ export default {
383 411 this.dataForm.storeDescription = raw.storeDescription || '';
384 412 this.dataForm.businessHours = raw.businessHours || '';
385 413 this.dataForm.trafficTips = raw.trafficTips || '';
  414 + const acf = raw.attendanceCheckFence != null ? Number(raw.attendanceCheckFence) : (raw.AttendanceCheckFence != null ? Number(raw.AttendanceCheckFence) : null);
  415 + const acw = raw.attendanceCheckWifi != null ? Number(raw.attendanceCheckWifi) : (raw.AttendanceCheckWifi != null ? Number(raw.AttendanceCheckWifi) : null);
  416 + this.dataForm.attendanceCheckFence = acf != null ? acf : 1;
  417 + this.dataForm.attendanceCheckWifi = acw != null ? acw : 0;
  418 + const avp = raw.attendanceWifiVerifyPair != null ? Number(raw.attendanceWifiVerifyPair) : (raw.AttendanceWifiVerifyPair != null ? Number(raw.AttendanceWifiVerifyPair) : null);
  419 + this.dataForm.attendanceWifiVerifyPair = avp != null ? avp : 0;
  420 + const apRaw = raw.attendanceWifiPairs != null ? raw.attendanceWifiPairs : raw.AttendanceWifiPairs;
  421 + this.dataForm.attendanceWifiPairList = this.parseWifiPairList(apRaw);
386 422 });
387 423 } else {
388 424 // 新建时重置经纬度与围栏
... ... @@ -394,10 +430,38 @@ export default {
394 430 this.dataForm.storeTags = [];
395 431 this.dataForm.businessHours = '';
396 432 this.dataForm.trafficTips = '';
  433 + this.dataForm.attendanceCheckFence = 0;
  434 + this.dataForm.attendanceCheckWifi = 0;
  435 + this.dataForm.attendanceWifiPairList = [];
  436 + this.dataForm.attendanceWifiVerifyPair = 0;
397 437 }
398 438 })
399 439 },
400 440  
  441 + addWifiPair() {
  442 + if (this.isDetail) return;
  443 + this.dataForm.attendanceWifiPairList.push({ ssid: '', bssid: '' });
  444 + },
  445 +
  446 + removeWifiPair(index) {
  447 + if (this.isDetail) return;
  448 + this.dataForm.attendanceWifiPairList.splice(index, 1);
  449 + },
  450 +
  451 + parseWifiPairList(value) {
  452 + const arr = this.parseJsonArray(value);
  453 + if (!arr.length) return [];
  454 + return arr.map((item) => {
  455 + if (item && typeof item === 'object') {
  456 + return {
  457 + ssid: (item.ssid != null ? String(item.ssid) : (item.Ssid != null ? String(item.Ssid) : '')).trim(),
  458 + bssid: (item.bssid != null ? String(item.bssid) : (item.Bssid != null ? String(item.Bssid) : '')).trim()
  459 + };
  460 + }
  461 + return { ssid: '', bssid: '' };
  462 + }).filter((p) => p.ssid || p.bssid);
  463 + },
  464 +
401 465 parseJsonArray(value) {
402 466 if (!value) return [];
403 467 if (Array.isArray(value)) return value;
... ... @@ -810,6 +874,16 @@ export default {
810 874 dataFormSubmit() {
811 875 this.$refs['elForm'].validate((valid) => {
812 876 if (!valid) return;
  877 + if (Number(this.dataForm.attendanceCheckWifi) === 1) {
  878 + const pairs = (this.dataForm.attendanceWifiPairList || []).map((r) => ({
  879 + ssid: (r.ssid || '').trim(),
  880 + bssid: (r.bssid || '').trim()
  881 + })).filter((p) => p.ssid || p.bssid);
  882 + if (!pairs.length) {
  883 + this.$message.warning('开启 Wi-Fi 打卡时请至少添加一行 Wi-Fi 白名单');
  884 + return;
  885 + }
  886 + }
813 887 const isNew = !this.dataForm.id;
814 888 // 提交前:fencePolygons 若为数组则序列化为 JSON 字符串,与后端约定一致
815 889 const submitData = { ...this.dataForm };
... ... @@ -822,6 +896,13 @@ export default {
822 896 if (Array.isArray(submitData.storeTags)) {
823 897 submitData.storeTags = JSON.stringify(submitData.storeTags);
824 898 }
  899 + delete submitData.attendanceWifiPairList;
  900 + const pairRows = (this.dataForm.attendanceWifiPairList || []).map((r) => ({
  901 + ssid: (r.ssid || '').trim(),
  902 + bssid: (r.bssid || '').trim()
  903 + })).filter((p) => p.ssid || p.bssid);
  904 + submitData.attendanceWifiPairs = JSON.stringify(pairRows);
  905 + submitData.attendanceWifiVerifyPair = this.dataForm.attendanceWifiVerifyPair != null ? Number(this.dataForm.attendanceWifiVerifyPair) : 0;
825 906 request({
826 907 url: isNew ? '/api/Extend/LqMdxx' : `/api/Extend/LqMdxx/${this.dataForm.id}`,
827 908 method: isNew ? 'POST' : 'PUT',
... ... @@ -844,6 +925,65 @@ export default {
844 925 </script>
845 926  
846 927 <style lang="scss" scoped>
  928 +.att-punch-check-row {
  929 + display: flex;
  930 + flex-wrap: wrap;
  931 + align-items: center;
  932 + gap: 16px 24px;
  933 +}
  934 +
  935 +.att-punch-hint {
  936 + margin-top: 8px;
  937 + font-size: 12px;
  938 + color: #909399;
  939 + line-height: 1.5;
  940 +}
  941 +
  942 +.wifi-verify-hint {
  943 + margin-top: 6px;
  944 +}
  945 +
  946 +.wifi-pair-toolbar {
  947 + margin-bottom: 10px;
  948 +}
  949 +
  950 +.wifi-pair-table {
  951 + width: 100%;
  952 + border: 1px solid #ebeef5;
  953 + border-radius: 4px;
  954 + overflow: hidden;
  955 +}
  956 +
  957 +.wifi-pair-head {
  958 + display: grid;
  959 + grid-template-columns: 1fr 1fr 56px;
  960 + gap: 8px;
  961 + align-items: center;
  962 + padding: 8px 10px;
  963 + background: #f5f7fa;
  964 + font-size: 12px;
  965 + color: #606266;
  966 + font-weight: 500;
  967 +}
  968 +
  969 +.wifi-pair-row {
  970 + display: grid;
  971 + grid-template-columns: 1fr 1fr 56px;
  972 + gap: 8px;
  973 + align-items: center;
  974 + padding: 8px 10px;
  975 + border-top: 1px solid #ebeef5;
  976 +}
  977 +
  978 +.wifi-pair-col-act {
  979 + text-align: right;
  980 +}
  981 +
  982 +.wifi-pair-del {
  983 + color: #f56c6c;
  984 + padding: 0;
  985 +}
  986 +
847 987 .fence-status-bar {
848 988 display: flex;
849 989 align-items: center;
... ... @@ -953,6 +1093,7 @@ export default {
953 1093 }
954 1094  
955 1095 .store-tags-editor {
  1096 +
956 1097 .input-new-tag,
957 1098 .tag-action-btn {
958 1099 width: 100%;
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqAttendanceRecord/LqAttendanceRecordPunchInput.cs
... ... @@ -61,5 +61,17 @@ namespace NCC.Extend.Entitys.Dto.LqAttendanceRecord
61 61 /// </summary>
62 62 [Display(Name = "备注")]
63 63 public string Remark { get; set; }
  64 +
  65 + /// <summary>
  66 + /// 当前连接 Wi-Fi 的 SSID(小程序上报,用于门店 Wi-Fi 打卡校验)
  67 + /// </summary>
  68 + [Display(Name = "Wi-Fi SSID")]
  69 + public string WifiSsid { get; set; }
  70 +
  71 + /// <summary>
  72 + /// 当前连接 Wi-Fi 的 BSSID(可选)
  73 + /// </summary>
  74 + [Display(Name = "Wi-Fi BSSID")]
  75 + public string WifiBssid { get; set; }
64 76 }
65 77 }
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqMdxx/LqMdxxCrInput.cs
... ... @@ -59,6 +59,26 @@ namespace NCC.Extend.Entitys.Dto.LqMdxx
59 59 public string fencePolygons { get; set; }
60 60  
61 61 /// <summary>
  62 + /// 正常打卡是否启用电子围栏校验(1-是,0-否;null 沿用后端默认)
  63 + /// </summary>
  64 + public int? attendanceCheckFence { get; set; }
  65 +
  66 + /// <summary>
  67 + /// 正常打卡是否启用 Wi-Fi 校验(1-是,0-否)
  68 + /// </summary>
  69 + public int? attendanceCheckWifi { get; set; }
  70 +
  71 + /// <summary>
  72 + /// Wi-Fi 成对配置(SSID+BSSID),JSON 数组字符串,如 [{"ssid":"x","bssid":"aa:bb:..."}]
  73 + /// </summary>
  74 + public string attendanceWifiPairs { get; set; }
  75 +
  76 + /// <summary>
  77 + /// 是否校验 SSID 与 BSSID 对应(1-是,0-否)
  78 + /// </summary>
  79 + public int? attendanceWifiVerifyPair { get; set; }
  80 +
  81 + /// <summary>
62 82 /// 姓名
63 83 /// </summary>
64 84 public string xm { get; set; }
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqMdxx/LqMdxxInfoOutput.cs
... ... @@ -61,6 +61,26 @@ namespace NCC.Extend.Entitys.Dto.LqMdxx
61 61 public string fencePolygons { get; set; }
62 62  
63 63 /// <summary>
  64 + /// 正常打卡是否启用电子围栏校验(1-是,0-否)
  65 + /// </summary>
  66 + public int? attendanceCheckFence { get; set; }
  67 +
  68 + /// <summary>
  69 + /// 正常打卡是否启用 Wi-Fi 校验(1-是,0-否)
  70 + /// </summary>
  71 + public int? attendanceCheckWifi { get; set; }
  72 +
  73 + /// <summary>
  74 + /// Wi-Fi 成对配置(SSID+BSSID),JSON 数组字符串
  75 + /// </summary>
  76 + public string attendanceWifiPairs { get; set; }
  77 +
  78 + /// <summary>
  79 + /// 是否校验 SSID 与 BSSID 对应(1-是,0-否)
  80 + /// </summary>
  81 + public int? attendanceWifiVerifyPair { get; set; }
  82 +
  83 + /// <summary>
64 84 /// 姓名
65 85 /// </summary>
66 86 public string xm { get; set; }
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/lq_mdxx/LqMdxxEntity.cs
... ... @@ -72,6 +72,30 @@ namespace NCC.Extend.Entitys.lq_mdxx
72 72 public string FencePolygons { get; set; }
73 73  
74 74 /// <summary>
  75 + /// 正常打卡是否启用电子围栏校验(1-是,0-否;null 表示沿用旧逻辑:已配置围栏则校验)
  76 + /// </summary>
  77 + [SugarColumn(ColumnName = "F_AttendanceCheckFence")]
  78 + public int? AttendanceCheckFence { get; set; }
  79 +
  80 + /// <summary>
  81 + /// 正常打卡是否启用 Wi-Fi(成对 SSID+BSSID)校验(1-是,0-否;null 视为 0)
  82 + /// </summary>
  83 + [SugarColumn(ColumnName = "F_AttendanceCheckWifi")]
  84 + public int? AttendanceCheckWifi { get; set; }
  85 +
  86 + /// <summary>
  87 + /// 允许打卡的 Wi-Fi 成对配置,JSON 数组,如 [{"ssid":"门店5G","bssid":"aa:bb:cc:dd:ee:ff"}]
  88 + /// </summary>
  89 + [SugarColumn(ColumnName = "F_AttendanceWifiPairs", ColumnDataType = "varchar(4000)")]
  90 + public string AttendanceWifiPairs { get; set; }
  91 +
  92 + /// <summary>
  93 + /// 是否校验 SSID 与 BSSID 为同一 AP(1-是:有 BSSID 时必须整对命中;无 BSSID 时需同时在围栏内且 SSID 命中;0-否:命中任一条的 SSID 或 BSSID 即可)
  94 + /// </summary>
  95 + [SugarColumn(ColumnName = "F_AttendanceWifiVerifyPair")]
  96 + public int? AttendanceWifiVerifyPair { get; set; }
  97 +
  98 + /// <summary>
75 99 /// 姓名
76 100 /// </summary>
77 101 [SugarColumn(ColumnName = "xm")]
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/wform_leave_cancel_apply/WformLeaveCancelApplyEntityLite.cs 0 → 100644
  1 +using System;
  2 +using NCC.Common.Const;
  3 +using SqlSugar;
  4 +
  5 +namespace NCC.Extend.Entitys.wform_leave_cancel_apply
  6 +{
  7 + /// <summary>
  8 + /// 销假申请单(精简映射:考勤详情关联流程)
  9 + /// </summary>
  10 + [SugarTable("WFORM_LEAVE_CANCEL_APPLY")]
  11 + [Tenant(ClaimConst.TENANT_ID)]
  12 + public class WformLeaveCancelApplyEntityLite
  13 + {
  14 + [SugarColumn(ColumnName = "F_Id", IsPrimaryKey = true)]
  15 + public string Id { get; set; }
  16 +
  17 + [SugarColumn(ColumnName = "F_FLOWID")]
  18 + public string FlowId { get; set; }
  19 +
  20 + [SugarColumn(ColumnName = "F_BILLNO")]
  21 + public string BillNo { get; set; }
  22 +
  23 + /// <summary>
  24 + /// 待销假的考勤记录主键(lq_attendance_record.F_Id)
  25 + /// </summary>
  26 + [SugarColumn(ColumnName = "F_ATTENDANCERECORDID")]
  27 + public string AttendanceRecordId { get; set; }
  28 +
  29 + [SugarColumn(ColumnName = "F_APPLYDATE")]
  30 + public DateTime? ApplyDate { get; set; }
  31 +
  32 + [SugarColumn(ColumnName = "F_CANCELREASON")]
  33 + public string CancelReason { get; set; }
  34 + }
  35 +}
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Enum/AttendanceRecordStatusEnum.cs
... ... @@ -47,6 +47,12 @@ namespace NCC.Extend.Entitys.Enum
47 47 /// 旷工
48 48 /// </summary>
49 49 [Description("旷工")]
50   - 旷工 = 7
  50 + 旷工 = 7,
  51 +
  52 + /// <summary>
  53 + /// 考勤日晚于当前日期、尚无打卡数据(界面展示「暂无打卡」,不计入缺卡类统计)
  54 + /// </summary>
  55 + [Description("暂无打卡")]
  56 + 待考勤 = 8
51 57 }
52 58 }
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqAttendanceRecordService.cs
... ... @@ -27,6 +27,7 @@ using NCC.Extend.Entitys.lq_attendance_missing_card_rule;
27 27 using NCC.Extend.Entitys.lq_attendance_record;
28 28 using NCC.Extend.Entitys.lq_attendance_setting;
29 29 using NCC.Extend.Entitys.lq_mdxx;
  30 +using NCC.Extend.Entitys.wform_leave_cancel_apply;
30 31 using NCC.Extend.Entitys.wform_leaveapply;
31 32 using NCC.Extend.Entitys.flow_task;
32 33 using NCC.Extend.Entitys.flow_engine;
... ... @@ -96,7 +97,8 @@ namespace NCC.Extend
96 97 /// 获取月度考勤记录矩阵
97 98 /// </summary>
98 99 /// <remarks>
99   - /// 按统计月份返回员工每日考勤状态;含打卡、补卡及请假流程同步后的「请假/病假」记录。
  100 + /// 按统计月份返回员工每日考勤状态;列表包含当前筛选条件下所有在职员工(含当月尚无考勤记录者),
  101 + /// 含打卡、补卡及请假流程同步后的「请假/病假」记录。
100 102 /// </remarks>
101 103 /// <param name="input">查询参数</param>
102 104 /// <returns>月度考勤记录矩阵</returns>
... ... @@ -227,59 +229,92 @@ namespace NCC.Extend
227 229  
228 230 var monthStart = new DateTime(monthDate.Year, monthDate.Month, 1);
229 231 var monthEnd = monthStart.AddMonths(1);
230   - var keyword = input.Keyword?.Trim();
231 232  
232   - var matchedRecords = await _db.Queryable<LqAttendanceRecordEntity>()
233   - .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode())
234   - .Where(x => x.AttendanceDate >= monthStart && x.AttendanceDate < monthEnd)
235   - .WhereIF(!string.IsNullOrWhiteSpace(input.AttendanceGroupId), x => x.AttendanceGroupId == input.AttendanceGroupId)
236   - .WhereIF(!string.IsNullOrWhiteSpace(keyword), x =>
237   - x.EmployeeName.Contains(keyword) ||
238   - x.StoreName.Contains(keyword) ||
239   - x.AttendanceGroupName.Contains(keyword))
240   - .ToListAsync();
  233 + var eligibleUsers = await QueryMonthReportEligibleUsersAsync(input);
  234 + var eligibleUserIds = eligibleUsers.Select(x => x.UserId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
  235 +
  236 + var matchedRecords = eligibleUserIds.Count == 0
  237 + ? new List<LqAttendanceRecordEntity>()
  238 + : await _db.Queryable<LqAttendanceRecordEntity>()
  239 + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode())
  240 + .Where(x => x.AttendanceDate >= monthStart && x.AttendanceDate < monthEnd)
  241 + .Where(x => eligibleUserIds.Contains(x.UserId))
  242 + .ToListAsync();
241 243  
242 244 matchedRecords = await RecalculateRecordStatusesAsync(matchedRecords);
243 245  
244   - var summary = BuildSummary(matchedRecords);
245   - var groupedEmployees = matchedRecords
246   - .GroupBy(x => new
  246 + var recordsByUserId = matchedRecords
  247 + .GroupBy(x => x.UserId)
  248 + .ToDictionary(x => x.Key, x => x.ToList());
  249 +
  250 + var summary = BuildSummary(matchedRecords, eligibleUsers.Count);
  251 + var groupedEmployees = eligibleUsers
  252 + .Select(u =>
247 253 {
248   - x.UserId,
249   - x.EmployeeName,
250   - x.StoreId,
251   - x.StoreName,
252   - x.AttendanceGroupId,
253   - x.AttendanceGroupName
254   - })
255   - .Select(g => new LqAttendanceRecordRowOutput
256   - {
257   - userId = g.Key.UserId,
258   - employeeName = g.Key.EmployeeName,
259   - storeId = g.Key.StoreId,
260   - storeName = g.Key.StoreName,
261   - attendanceGroupId = g.Key.AttendanceGroupId,
262   - groupName = g.Key.AttendanceGroupName,
263   - monthStats = new LqAttendanceRecordMonthStatOutput
  254 + recordsByUserId.TryGetValue(u.UserId, out var userRecords);
  255 + userRecords ??= new List<LqAttendanceRecordEntity>();
  256 + return new LqAttendanceRecordRowOutput
264 257 {
265   - normal = g.Count(x => x.Status == (int)AttendanceRecordStatusEnum.正常),
266   - late = g.Count(x => x.Status == (int)AttendanceRecordStatusEnum.迟到),
267   - abnormal = g.Count(x => IsAbnormalStatus(x.Status))
268   - },
269   - dayRecords = g.GroupBy(x => x.AttendanceDate.Date)
270   - .Select(dayGroup => BuildDayItem(dayGroup.OrderByDescending(x => x.UpdateTime ?? x.CreateTime ?? DateTime.MinValue).First()))
271   - .OrderBy(x => x.date)
272   - .ToList()
  258 + userId = u.UserId,
  259 + employeeName = u.EmployeeName,
  260 + storeId = u.StoreId,
  261 + storeName = u.StoreName,
  262 + attendanceGroupId = u.AttendanceGroupId,
  263 + groupName = u.GroupName,
  264 + monthStats = new LqAttendanceRecordMonthStatOutput
  265 + {
  266 + normal = userRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.正常),
  267 + late = userRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.迟到),
  268 + abnormal = userRecords.Count(x => IsAbnormalStatus(x.Status))
  269 + },
  270 + dayRecords = userRecords
  271 + .GroupBy(x => x.AttendanceDate.Date)
  272 + .Select(dayGroup => BuildDayItem(dayGroup.OrderByDescending(x => x.UpdateTime ?? x.CreateTime ?? DateTime.MinValue).First()))
  273 + .OrderBy(x => x.date)
  274 + .ToList()
  275 + };
273 276 })
274   - .OrderBy(x => x.storeName)
275   - .ThenBy(x => x.groupName)
276   - .ThenBy(x => x.employeeName)
277 277 .ToList();
278 278  
279 279 return (groupedEmployees, summary);
280 280 }
281 281  
282 282 /// <summary>
  283 + /// 月度矩阵:当前筛选条件下的在职员工(主数据),与当月是否有考勤行无关。
  284 + /// </summary>
  285 + private async Task<List<MonthReportUserLine>> QueryMonthReportEligibleUsersAsync(LqAttendanceRecordMonthQueryInput input)
  286 + {
  287 + var keyword = input.Keyword?.Trim();
  288 + var lines = await _db.Queryable<UserEntity, LqMdxxEntity, LqAttendanceGroupEntity>((u, s, g) => new JoinQueryInfos(
  289 + JoinType.Left, s.Id == u.Mdid,
  290 + JoinType.Left, g.Id == u.AttendanceGroupId))
  291 + .Where((u, s, g) => u.DeleteMark == null && SqlFunc.IsNull(u.IsOnJob, 1) == 1)
  292 + .WhereIF(!string.IsNullOrWhiteSpace(input.AttendanceGroupId),
  293 + (u, s, g) => u.AttendanceGroupId == input.AttendanceGroupId)
  294 + .WhereIF(!string.IsNullOrWhiteSpace(keyword), (u, s, g) =>
  295 + SqlFunc.Contains(SqlFunc.IsNull(u.RealName, ""), keyword) ||
  296 + SqlFunc.Contains(SqlFunc.IsNull(u.Account, ""), keyword) ||
  297 + SqlFunc.Contains(SqlFunc.IsNull(s.Dm, ""), keyword) ||
  298 + SqlFunc.Contains(SqlFunc.IsNull(g.GroupName, ""), keyword))
  299 + .Select((u, s, g) => new MonthReportUserLine
  300 + {
  301 + UserId = u.Id,
  302 + EmployeeName = u.RealName,
  303 + StoreId = u.Mdid,
  304 + StoreName = s.Dm,
  305 + AttendanceGroupId = u.AttendanceGroupId,
  306 + GroupName = g.GroupName
  307 + })
  308 + .ToListAsync();
  309 +
  310 + return lines
  311 + .OrderBy(x => x.StoreName ?? "")
  312 + .ThenBy(x => x.GroupName ?? "")
  313 + .ThenBy(x => x.EmployeeName ?? "")
  314 + .ToList();
  315 + }
  316 +
  317 + /// <summary>
283 318 /// 提交打卡
284 319 /// </summary>
285 320 /// <remarks>
... ... @@ -327,6 +362,19 @@ namespace NCC.Extend
327 362  
328 363 var punchDirection = (AttendancePunchDirectionEnum)input.PunchDirection;
329 364 var punchType = input.PunchType;
  365 + if (punchType == (int)AttendancePunchTypeEnum.正常上班)
  366 + {
  367 + if ((store.AttendanceCheckWifi ?? 0) == 1 && ParseStoreWifiPairs(store).Count == 0)
  368 + {
  369 + throw NCCException.Oh("门店已开启 Wi-Fi 打卡但未配置网络白名单,请联系管理员在门店资料中维护");
  370 + }
  371 +
  372 + if (!IsNormalPunchLocationSatisfied(store, input.Longitude, input.Latitude, input.WifiSsid, input.WifiBssid))
  373 + {
  374 + throw NCCException.Oh("未满足门店打卡要求(需在打卡范围内或连接门店指定 Wi-Fi/路由器),可改用外勤打卡");
  375 + }
  376 + }
  377 +
330 378 var fenceValid = EvaluateFenceValid(store, input.Longitude, input.Latitude);
331 379 ApplyPunch(record, punchDirection, punchType, punchTime, input.Longitude, input.Latitude, input.Address, input.PhotoUrl, fenceValid);
332 380  
... ... @@ -374,18 +422,33 @@ namespace NCC.Extend
374 422 var record = await GetAttendanceRecordAsync(input);
375 423 if (record == null)
376 424 {
  425 + if (!string.IsNullOrWhiteSpace(input.UserId) && !string.IsNullOrWhiteSpace(input.AttendanceDate))
  426 + {
  427 + return await BuildEmptyAttendanceDetailAsync(input);
  428 + }
  429 +
377 430 throw NCCException.Oh("未找到对应的打卡记录");
378 431 }
379 432  
  433 + var statusBeforeDetailRecalc = record.Status;
380 434 var recalculated = await RecalculateRecordStatusesAsync(new List<LqAttendanceRecordEntity> { record });
381 435 record = recalculated.FirstOrDefault() ?? record;
  436 + // 重算后状态变化时落库(含:备注已销假但库中仍为请假、待考勤已到期应转为缺卡等)
  437 + if (record.Status != statusBeforeDetailRecalc)
  438 + {
  439 + await SaveAttendanceRecordAsync(record);
  440 + }
  441 +
382 442 var group = await GetAttendanceGroupAsync(record.AttendanceGroupId, false);
383 443  
384 444 var relatedWorkflows = ParseRelatedWorkflows(record.RelatedWorkflowsJson);
385 445  
  446 + // 销假后考勤状态可能会从“请假/病假”恢复到“正常/缺卡”等,
  447 + // 但 record.Remark 仍可能保留“请假审批通过:...;销假:...”信息。
  448 + // 因此解析 leaveType/billNo 时不再强依赖 isLeaveStatus。
386 449 var isLeaveStatus = record.Status == (int)AttendanceRecordStatusEnum.请假
387 450 || record.Status == (int)AttendanceRecordStatusEnum.病假;
388   - if (!relatedWorkflows.Any(x => x.type == "请假") && isLeaveStatus
  451 + if (!relatedWorkflows.Any(x => x.type == "请假")
389 452 && TryParseLeaveApplyFromSyncRemark(record.Remark, out var parsedLeaveType, out var parsedBillNo)
390 453 && !string.IsNullOrWhiteSpace(parsedBillNo))
391 454 {
... ... @@ -406,6 +469,31 @@ namespace NCC.Extend
406 469 });
407 470 }
408 471  
  472 + var cancelApplyRows = await _db.Queryable<WformLeaveCancelApplyEntityLite>()
  473 + .Where(x => x.AttendanceRecordId == record.Id)
  474 + .OrderBy(x => x.ApplyDate, OrderByType.Desc)
  475 + .ToListAsync();
  476 + foreach (var cancelApply in cancelApplyRows)
  477 + {
  478 + if (cancelApply == null || string.IsNullOrWhiteSpace(cancelApply.Id))
  479 + {
  480 + continue;
  481 + }
  482 +
  483 + if (relatedWorkflows.Any(x => x.type == "销假" && x.id == cancelApply.Id))
  484 + {
  485 + continue;
  486 + }
  487 +
  488 + relatedWorkflows.Add(new RelatedWorkflowItem
  489 + {
  490 + type = "销假",
  491 + id = cancelApply.Id,
  492 + billNo = cancelApply.BillNo,
  493 + remark = SafeTrim(cancelApply.CancelReason)
  494 + });
  495 + }
  496 +
409 497 if (!string.IsNullOrWhiteSpace(record.SupplementWorkflowId) && !relatedWorkflows.Any(x => x.type == "补卡"))
410 498 {
411 499 relatedWorkflows.Add(new RelatedWorkflowItem
... ... @@ -1060,7 +1148,9 @@ namespace NCC.Extend
1060 1148 }
1061 1149 else if (!record.PunchInTime.HasValue && !record.PunchOutTime.HasValue)
1062 1150 {
1063   - targetStatus = (int)AttendanceRecordStatusEnum.缺卡;
  1151 + targetStatus = attendanceDate > today
  1152 + ? (int)AttendanceRecordStatusEnum.待考勤
  1153 + : (int)AttendanceRecordStatusEnum.缺卡;
1064 1154 }
1065 1155 else
1066 1156 {
... ... @@ -1472,11 +1562,11 @@ namespace NCC.Extend
1472 1562 };
1473 1563 }
1474 1564  
1475   - private static dynamic BuildSummary(List<LqAttendanceRecordEntity> matchedRecords)
  1565 + private static dynamic BuildSummary(List<LqAttendanceRecordEntity> matchedRecords, int displayedEmployeeCount)
1476 1566 {
1477 1567 return new
1478 1568 {
1479   - employeeCount = matchedRecords.Select(x => x.UserId).Distinct().Count(),
  1569 + employeeCount = displayedEmployeeCount,
1480 1570 normalCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.正常),
1481 1571 lateCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.迟到),
1482 1572 leaveAndMissingCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.请假
... ... @@ -1667,9 +1757,28 @@ namespace NCC.Extend
1667 1757  
1668 1758 private async Task<LqAttendanceRecordEntity> GetAttendanceRecordByUserDateAsync(string userId, DateTime attendanceDate)
1669 1759 {
1670   - return await _db.Queryable<LqAttendanceRecordEntity>()
1671   - .Where(x => x.UserId == userId && x.AttendanceDate >= attendanceDate.Date && x.AttendanceDate < attendanceDate.Date.AddDays(1) && x.IsEffective == StatusEnum.有效.GetHashCode())
1672   - .FirstAsync();
  1760 + var list = await _db.Queryable<LqAttendanceRecordEntity>()
  1761 + .Where(x => x.UserId == userId
  1762 + && x.AttendanceDate >= attendanceDate.Date
  1763 + && x.AttendanceDate < attendanceDate.Date.AddDays(1)
  1764 + && x.IsEffective == StatusEnum.有效.GetHashCode())
  1765 + .Take(1)
  1766 + .ToListAsync();
  1767 + return list.FirstOrDefault();
  1768 + }
  1769 +
  1770 + /// <summary>
  1771 + /// 不限制 IsEffective:用于考勤详情兜底展示(例如销假把“未来占位行”置为无效后仍希望看到关联请假/销假历史)。
  1772 + /// </summary>
  1773 + private async Task<LqAttendanceRecordEntity> GetAttendanceRecordByUserDateAnyAsync(string userId, DateTime attendanceDate)
  1774 + {
  1775 + var list = await _db.Queryable<LqAttendanceRecordEntity>()
  1776 + .Where(x => x.UserId == userId
  1777 + && x.AttendanceDate >= attendanceDate.Date
  1778 + && x.AttendanceDate < attendanceDate.Date.AddDays(1))
  1779 + .Take(1)
  1780 + .ToListAsync();
  1781 + return list.FirstOrDefault();
1673 1782 }
1674 1783  
1675 1784 /// <param name="bypassSnapshotPairSkip">为 true 时不过滤「规则快照与全量快照均已存在」的记录(用于历史缺卡批量修正)</param>
... ... @@ -1723,31 +1832,60 @@ namespace NCC.Extend
1723 1832  
1724 1833 foreach (var record in records)
1725 1834 {
1726   - // 流程同步写入的请假/病假(含应休「休假」、年假等):勿按打卡重算覆盖为缺卡/正常,否则详情无法销假、应休/年假额度无法按考勤折算还原
  1835 + // 流程同步写入的请假/病假(含应休「休假」、年假等):勿按打卡重算覆盖为缺卡/正常,否则详情无法销假、应休/年假额度无法按考勤折算还原。
  1836 + // 备注已含「销假」时不再跳过:否则会出现库中仍为请假/病假但备注已销假、详情与列表长期不一致。
1727 1837 if (record.IsManual == 1
1728 1838 && (record.Status == (int)AttendanceRecordStatusEnum.请假 || record.Status == (int)AttendanceRecordStatusEnum.病假)
1729 1839 && !string.IsNullOrWhiteSpace(record.Remark)
1730   - && record.Remark.IndexOf("请假审批通过:", StringComparison.Ordinal) >= 0)
  1840 + && record.Remark.IndexOf("请假审批通过:", StringComparison.Ordinal) >= 0
  1841 + && record.Remark.IndexOf("销假", StringComparison.Ordinal) < 0)
1731 1842 {
1732 1843 continue;
1733 1844 }
1734 1845  
  1846 + var leaveSyncedButCancelledInRemark = !string.IsNullOrWhiteSpace(record.Remark)
  1847 + && record.Remark.IndexOf("销假", StringComparison.Ordinal) >= 0
  1848 + && (record.Status == (int)AttendanceRecordStatusEnum.请假
  1849 + || record.Status == (int)AttendanceRecordStatusEnum.病假);
  1850 +
  1851 + var attendanceDate = record.AttendanceDate.Date;
  1852 + var todayDate = DateTime.Today.Date;
  1853 + // 「待考勤」仅适用于考勤日晚于今日;已到期应重新按打卡情况判定(否则快照非空时会一直被跳过)
  1854 + var pendingAttendanceExpired = record.Status == (int)AttendanceRecordStatusEnum.待考勤
  1855 + && attendanceDate <= todayDate;
  1856 + // 库中仍是「缺卡」但考勤日晚于今日且无打卡时,应展示为待考勤;否则快照非空会一直跳过重算,永远停留在缺卡
  1857 + var futureDayMissingShouldBePending = attendanceDate > todayDate
  1858 + && record.Status == (int)AttendanceRecordStatusEnum.缺卡
  1859 + && !record.PunchInTime.HasValue
  1860 + && !record.PunchOutTime.HasValue;
  1861 +
1735 1862 if (!bypassSnapshotPairSkip
1736 1863 && !string.IsNullOrWhiteSpace(record.RuleSnapshotJson)
1737   - && !string.IsNullOrWhiteSpace(record.AllRuleSnapshotJson))
  1864 + && !string.IsNullOrWhiteSpace(record.AllRuleSnapshotJson)
  1865 + && !leaveSyncedButCancelledInRemark
  1866 + && !pendingAttendanceExpired
  1867 + && !futureDayMissingShouldBePending)
1738 1868 {
1739 1869 continue;
1740 1870 }
1741 1871  
1742   - var attendanceDate = record.AttendanceDate.Date;
1743 1872 var group = ResolveRecordAttendanceGroup(record, userDict, groupDict);
1744 1873 var isHoliday = holidayDates.Contains(attendanceDate);
1745 1874 var isExempt = IsExemptForDate(exemptUsers, record.UserId, attendanceDate);
  1875 + // 备注已销假但状态未落库时,勿把「请假/病假」传入 Resolve(否则会原样保留请假);
  1876 + // 「待考勤」已到期时同样按正常流程重算为缺卡/正常等;
  1877 + // 未来日+缺卡无打卡时勿把「缺卡」传入 Resolve(否则会原样保留缺卡)。
  1878 + var statusForResolution = record.Status;
  1879 + if (leaveSyncedButCancelledInRemark || pendingAttendanceExpired || futureDayMissingShouldBePending)
  1880 + {
  1881 + statusForResolution = (int)AttendanceRecordStatusEnum.正常;
  1882 + }
  1883 +
1746 1884 var statusContext = ResolveAttendanceStatus(
1747 1885 attendanceDate,
1748 1886 group,
1749 1887 lateRules,
1750   - record.Status,
  1888 + statusForResolution,
1751 1889 record.PunchInTime,
1752 1890 record.PunchOutTime,
1753 1891 null,
... ... @@ -1807,9 +1945,19 @@ namespace NCC.Extend
1807 1945 {
1808 1946 if (!string.IsNullOrWhiteSpace(input.Id))
1809 1947 {
1810   - return await _db.Queryable<LqAttendanceRecordEntity>()
  1948 + var effectiveList = await _db.Queryable<LqAttendanceRecordEntity>()
1811 1949 .Where(x => x.Id == input.Id && x.IsEffective == StatusEnum.有效.GetHashCode())
1812   - .FirstAsync();
  1950 + .Take(1)
  1951 + .ToListAsync();
  1952 + var effectiveRecord = effectiveList.FirstOrDefault();
  1953 + if (effectiveRecord != null) return effectiveRecord;
  1954 +
  1955 + // 兜底:允许返回无效行(用于展示请假/销假关联历史)
  1956 + var anyList = await _db.Queryable<LqAttendanceRecordEntity>()
  1957 + .Where(x => x.Id == input.Id)
  1958 + .Take(1)
  1959 + .ToListAsync();
  1960 + return anyList.FirstOrDefault();
1813 1961 }
1814 1962  
1815 1963 if (string.IsNullOrWhiteSpace(input.UserId) || string.IsNullOrWhiteSpace(input.AttendanceDate))
... ... @@ -1818,7 +1966,11 @@ namespace NCC.Extend
1818 1966 }
1819 1967  
1820 1968 var attendanceDate = ParseAttendanceDate(input.AttendanceDate);
1821   - return await GetAttendanceRecordByUserDateAsync(input.UserId, attendanceDate);
  1969 + var effectiveRecordByDate = await GetAttendanceRecordByUserDateAsync(input.UserId, attendanceDate);
  1970 + if (effectiveRecordByDate != null) return effectiveRecordByDate;
  1971 +
  1972 + // 兜底:允许返回无效行(用于展示请假/销假关联历史)
  1973 + return await GetAttendanceRecordByUserDateAnyAsync(input.UserId, attendanceDate);
1822 1974 }
1823 1975  
1824 1976 private static LqAttendanceRecordEntity CreateAttendanceRecord(UserEntity user, LqMdxxEntity store, LqAttendanceGroupEntity group, DateTime attendanceDate, string operatorUserId)
... ... @@ -1834,7 +1986,9 @@ namespace NCC.Extend
1834 1986 AttendanceGroupId = group?.Id,
1835 1987 AttendanceGroupName = group?.GroupName,
1836 1988 AttendanceDate = attendanceDate.Date,
1837   - Status = (int)AttendanceRecordStatusEnum.缺卡,
  1989 + Status = attendanceDate.Date > DateTime.Today.Date
  1990 + ? (int)AttendanceRecordStatusEnum.待考勤
  1991 + : (int)AttendanceRecordStatusEnum.缺卡,
1838 1992 LateMinutes = 0,
1839 1993 EarlyLeaveMinutes = 0,
1840 1994 IsManual = 0,
... ... @@ -1956,9 +2110,12 @@ namespace NCC.Extend
1956 2110 // 取消流程补卡、销假回退等场景会清空打卡时间;双无打卡不应判为「正常」,与销假后逻辑一致(见 ExecuteCancelLeaveCoreAsync)
1957 2111 if (!punchInTime.HasValue && !punchOutTime.HasValue)
1958 2112 {
  2113 + var noPunchStatus = attendanceDate.Date > DateTime.Today.Date
  2114 + ? (int)AttendanceRecordStatusEnum.待考勤
  2115 + : (int)AttendanceRecordStatusEnum.缺卡;
1959 2116 return new AttendanceStatusContext
1960 2117 {
1961   - Status = (int)AttendanceRecordStatusEnum.缺卡,
  2118 + Status = noPunchStatus,
1962 2119 LateMinutes = 0,
1963 2120 EarlyLeaveMinutes = 0
1964 2121 };
... ... @@ -2005,6 +2162,154 @@ namespace NCC.Extend
2005 2162 return lateRules.Any(rule => minutes >= rule.MinMinutes && (!rule.MaxMinutes.HasValue || minutes < rule.MaxMinutes.Value));
2006 2163 }
2007 2164  
  2165 + private static bool StoreHasFencePolygons(LqMdxxEntity store) =>
  2166 + store != null && !string.IsNullOrWhiteSpace(store.FencePolygons);
  2167 +
  2168 + /// <summary>
  2169 + /// 是否实际启用围栏校验(门店开启且已配置围栏多边形)
  2170 + /// </summary>
  2171 + private static bool EffectiveRequireFenceCheck(LqMdxxEntity store)
  2172 + {
  2173 + if (!StoreHasFencePolygons(store)) return false;
  2174 + var f = store.AttendanceCheckFence;
  2175 + if (!f.HasValue) return true;
  2176 + return f.Value == 1;
  2177 + }
  2178 +
  2179 + /// <summary>
  2180 + /// 统一 BSSID 格式:去空白、连字符改冒号、小写;无分隔符的 12 位十六进制转为 aa:bb:cc:dd:ee:ff
  2181 + /// </summary>
  2182 + private static string NormalizeAttendanceBssid(string value)
  2183 + {
  2184 + if (string.IsNullOrWhiteSpace(value)) return string.Empty;
  2185 + var s = value.Trim().Replace("-", ":").Replace(" ", string.Empty).ToLowerInvariant();
  2186 + if (s.IndexOf(':') < 0 && s.Length == 12)
  2187 + {
  2188 + var allHex = true;
  2189 + foreach (var c in s)
  2190 + {
  2191 + if ((c < '0' || c > '9') && (c < 'a' || c > 'f'))
  2192 + {
  2193 + allHex = false;
  2194 + break;
  2195 + }
  2196 + }
  2197 +
  2198 + if (allHex)
  2199 + {
  2200 + return string.Join(":", Enumerable.Range(0, 6).Select(i => s.Substring(i * 2, 2)));
  2201 + }
  2202 + }
  2203 +
  2204 + return s;
  2205 + }
  2206 +
  2207 + /// <summary>
  2208 + /// 解析门店 Wi-Fi 成对配置(ssid + bssid)
  2209 + /// </summary>
  2210 + private static List<(string Ssid, string Bssid)> ParseStoreWifiPairs(LqMdxxEntity store)
  2211 + {
  2212 + var list = new List<(string Ssid, string Bssid)>();
  2213 + if (store == null || string.IsNullOrWhiteSpace(store.AttendanceWifiPairs)) return list;
  2214 + try
  2215 + {
  2216 + var token = JToken.Parse(store.AttendanceWifiPairs.Trim());
  2217 + if (token is not JArray arr) return list;
  2218 + foreach (var item in arr)
  2219 + {
  2220 + if (item is not JObject jo) continue;
  2221 + var ssid = jo["ssid"]?.ToString()?.Trim() ?? jo["Ssid"]?.ToString()?.Trim() ?? string.Empty;
  2222 + var bssid = jo["bssid"]?.ToString()?.Trim() ?? jo["Bssid"]?.ToString()?.Trim() ?? string.Empty;
  2223 + if (!string.IsNullOrEmpty(ssid) || !string.IsNullOrEmpty(bssid))
  2224 + {
  2225 + list.Add((ssid, bssid));
  2226 + }
  2227 + }
  2228 + }
  2229 + catch
  2230 + {
  2231 + // ignore invalid json
  2232 + }
  2233 +
  2234 + return list;
  2235 + }
  2236 +
  2237 + /// <summary>
  2238 + /// 是否启用 Wi-Fi 侧校验(门店勾选 Wi-Fi 打卡即视为需要;白名单是否已配置由 <see cref="IsWifiPunchAllowed"/> 判断)
  2239 + /// </summary>
  2240 + /// <remarks>
  2241 + /// 若此处要求「必须已有成对数据」才为 true,则未配白名单时前端会显示「未要求 Wi-Fi」且可能放过打卡,与门店勾选意图不符。
  2242 + /// </remarks>
  2243 + private static bool EffectiveRequireWifiCheck(LqMdxxEntity store)
  2244 + {
  2245 + if (store == null || (store.AttendanceCheckWifi ?? 0) != 1) return false;
  2246 + return true;
  2247 + }
  2248 +
  2249 + /// <summary>
  2250 + /// Wi-Fi 是否满足打卡规则(仅 <see cref="ParseStoreWifiPairs"/>)
  2251 + /// </summary>
  2252 + private static bool IsWifiPunchAllowed(LqMdxxEntity store, decimal? longitude, decimal? latitude, string wifiSsid, string wifiBssid)
  2253 + {
  2254 + var pairs = ParseStoreWifiPairs(store);
  2255 + if (pairs.Count > 0)
  2256 + {
  2257 + var strict = (store.AttendanceWifiVerifyPair ?? 0) == 1;
  2258 + var clientSsid = (wifiSsid ?? string.Empty).Trim();
  2259 + var clientBssidNorm = NormalizeAttendanceBssid(wifiBssid);
  2260 + var hasClientBssid = !string.IsNullOrEmpty(clientBssidNorm);
  2261 +
  2262 + if (strict)
  2263 + {
  2264 + if (hasClientBssid)
  2265 + {
  2266 + // 微信/iOS 常拿不到 SSID 或名称不准,以 BSSID(AP)为准;配置行须含有效 BSSID
  2267 + return pairs.Any(p =>
  2268 + {
  2269 + var pb = NormalizeAttendanceBssid(p.Bssid);
  2270 + return !string.IsNullOrEmpty(pb) && pb == clientBssidNorm;
  2271 + });
  2272 + }
  2273 +
  2274 + if (EvaluateFenceValid(store, longitude, latitude) != 1) return false;
  2275 + if (string.IsNullOrEmpty(clientSsid)) return false;
  2276 + return pairs.Any(p =>
  2277 + {
  2278 + var ps = (p.Ssid ?? string.Empty).Trim();
  2279 + return !string.IsNullOrEmpty(ps) && string.Equals(ps, clientSsid, StringComparison.OrdinalIgnoreCase);
  2280 + });
  2281 + }
  2282 +
  2283 + return pairs.Any(p =>
  2284 + {
  2285 + var ps = (p.Ssid ?? string.Empty).Trim();
  2286 + var pb = NormalizeAttendanceBssid(p.Bssid);
  2287 + var ssidHit = !string.IsNullOrEmpty(ps) && !string.IsNullOrEmpty(clientSsid) &&
  2288 + string.Equals(ps, clientSsid, StringComparison.OrdinalIgnoreCase);
  2289 + var bssidHit = !string.IsNullOrEmpty(pb) && pb == clientBssidNorm;
  2290 + return ssidHit || bssidHit;
  2291 + });
  2292 + }
  2293 +
  2294 + return false;
  2295 + }
  2296 +
  2297 + /// <summary>
  2298 + /// 正常打卡是否满足门店位置策略:仅围栏 / 仅 Wi-Fi / 两者同时开启时为「围栏或 Wi-Fi 满足其一」
  2299 + /// </summary>
  2300 + private static bool IsNormalPunchLocationSatisfied(LqMdxxEntity store, decimal? longitude, decimal? latitude, string wifiSsid, string wifiBssid)
  2301 + {
  2302 + var needFence = EffectiveRequireFenceCheck(store);
  2303 + var needWifi = EffectiveRequireWifiCheck(store);
  2304 + if (!needFence && !needWifi) return true;
  2305 +
  2306 + var fenceOk = !needFence || EvaluateFenceValid(store, longitude, latitude) == 1;
  2307 + var wifiOk = !needWifi || IsWifiPunchAllowed(store, longitude, latitude, wifiSsid, wifiBssid);
  2308 + if (needFence && needWifi) return fenceOk || wifiOk;
  2309 + if (needFence) return fenceOk;
  2310 + return wifiOk;
  2311 + }
  2312 +
2008 2313 private static int? EvaluateFenceValid(LqMdxxEntity store, decimal? longitude, decimal? latitude)
2009 2314 {
2010 2315 if (store == null || !longitude.HasValue || !latitude.HasValue || string.IsNullOrWhiteSpace(store.FencePolygons))
... ... @@ -2246,12 +2551,77 @@ namespace NCC.Extend
2246 2551 w.taskNodeId
2247 2552 }).ToList(),
2248 2553 canCancelWorkflowSupplement,
  2554 + hasAttendanceRecord = true,
2249 2555 ruleSnapshotJson = record.RuleSnapshotJson,
2250 2556 allRuleSnapshotJson = record.AllRuleSnapshotJson
2251 2557 };
2252 2558 }
2253 2559  
2254 2560 /// <summary>
  2561 + /// 当日无考勤行时仍返回详情结构,便于前端展示「暂无打卡」。
  2562 + /// </summary>
  2563 + private async Task<dynamic> BuildEmptyAttendanceDetailAsync(LqAttendanceRecordDetailQueryInput input)
  2564 + {
  2565 + var user = await GetAttendanceUserAsync(input.UserId.Trim(), requireOnJob: false);
  2566 + var attendanceDate = ParseAttendanceDate(input.AttendanceDate);
  2567 + var store = await GetStoreAsync(user.Mdid);
  2568 + var group = await GetAttendanceGroupAsync(user.AttendanceGroupId, required: false);
  2569 + var missingStatus = attendanceDate.Date > DateTime.Today.Date
  2570 + ? (int)AttendanceRecordStatusEnum.待考勤
  2571 + : (int)AttendanceRecordStatusEnum.缺卡;
  2572 +
  2573 + return new
  2574 + {
  2575 + id = (string)null,
  2576 + userId = user.Id,
  2577 + employeeName = user.RealName,
  2578 + storeId = user.Mdid,
  2579 + storeName = store?.Dm,
  2580 + attendanceDate = attendanceDate.ToString("yyyy-MM-dd"),
  2581 + attendanceGroupId = user.AttendanceGroupId,
  2582 + attendanceGroupName = group?.GroupName,
  2583 + workStartTime = group?.WorkStartTime,
  2584 + workEndTime = group?.WorkEndTime,
  2585 + status = missingStatus,
  2586 + statusText = "暂无打卡",
  2587 + lateMinutes = 0,
  2588 + earlyLeaveMinutes = 0,
  2589 + isManual = 0,
  2590 + remark = (string)null,
  2591 + punchIn = new
  2592 + {
  2593 + time = (string)null,
  2594 + type = (int?)null,
  2595 + typeText = "暂无打卡",
  2596 + longitude = (decimal?)null,
  2597 + latitude = (decimal?)null,
  2598 + address = (string)null,
  2599 + photoUrl = (string)null,
  2600 + fenceValid = (int?)null,
  2601 + fenceValidText = "未校验"
  2602 + },
  2603 + punchOut = new
  2604 + {
  2605 + time = (string)null,
  2606 + type = (int?)null,
  2607 + typeText = "暂无打卡",
  2608 + longitude = (decimal?)null,
  2609 + latitude = (decimal?)null,
  2610 + address = (string)null,
  2611 + photoUrl = (string)null,
  2612 + fenceValid = (int?)null,
  2613 + fenceValidText = "未校验"
  2614 + },
  2615 + supplement = (object)null,
  2616 + relatedWorkflows = new List<object>(),
  2617 + canCancelWorkflowSupplement = false,
  2618 + hasAttendanceRecord = false,
  2619 + ruleSnapshotJson = (string)null,
  2620 + allRuleSnapshotJson = (string)null
  2621 + };
  2622 + }
  2623 +
  2624 + /// <summary>
2255 2625 /// 是否允许「取消流程补卡」:能解析到补卡申请单,且申请目标中存在与本考勤日对应的勾选项(含 PascalCase、日期格式差异、单日申请兜底)。
2256 2626 /// </summary>
2257 2627 private async Task<bool> ComputeCanCancelWorkflowSupplementAsync(LqAttendanceRecordEntity record)
... ... @@ -2770,6 +3140,8 @@ namespace NCC.Extend
2770 3140 return null;
2771 3141 }
2772 3142  
  3143 + var wifiPairRows = ParseStoreWifiPairs(entity);
  3144 + var wifiPairsPayload = wifiPairRows.Select(p => new { ssid = p.Ssid, bssid = p.Bssid }).ToList();
2773 3145 return new
2774 3146 {
2775 3147 storeId = entity.Id,
... ... @@ -2781,7 +3153,13 @@ namespace NCC.Extend
2781 3153 fencePolygons = entity.FencePolygons,
2782 3154 hasFence = !string.IsNullOrWhiteSpace(entity.FencePolygons),
2783 3155 businessHours = entity.BusinessHours,
2784   - trafficTips = entity.TrafficTips
  3156 + trafficTips = entity.TrafficTips,
  3157 + attendanceCheckFence = entity.AttendanceCheckFence,
  3158 + attendanceCheckWifi = entity.AttendanceCheckWifi,
  3159 + attendanceWifiPairs = wifiPairsPayload,
  3160 + attendanceWifiVerifyPair = entity.AttendanceWifiVerifyPair ?? 0,
  3161 + requireFenceCheck = EffectiveRequireFenceCheck(entity),
  3162 + requireWifiCheck = EffectiveRequireWifiCheck(entity)
2785 3163 };
2786 3164 }
2787 3165  
... ... @@ -3032,6 +3410,13 @@ namespace NCC.Extend
3032 3410 private static string GetAttendanceRecordStatusDisplayText(LqAttendanceRecordEntity record)
3033 3411 {
3034 3412 var (_, statusText, _) = MapStatus(record.Status);
  3413 + // 备注中已记录销假时,不再用「请假审批通过」里的假种名作为状态 headline(避免仍显示年假等)
  3414 + if (!string.IsNullOrWhiteSpace(record.Remark)
  3415 + && record.Remark.IndexOf("销假", StringComparison.Ordinal) >= 0)
  3416 + {
  3417 + return statusText;
  3418 + }
  3419 +
3035 3420 if (record.Status == (int)AttendanceRecordStatusEnum.请假)
3036 3421 {
3037 3422 var label = TryGetLeaveTypeLabelFromSyncRemark(record.Remark);
... ... @@ -3157,6 +3542,7 @@ namespace NCC.Extend
3157 3542 }
3158 3543  
3159 3544 if (statusKey == "sick") return "病假";
  3545 + if (statusKey == "pending") return "暂无打卡";
3160 3546 if (statusKey == "missing") return "缺卡";
3161 3547 if (statusKey == "absenteeism") return "旷工";
3162 3548 return "--";
... ... @@ -3181,6 +3567,8 @@ namespace NCC.Extend
3181 3567 return ("sick", "病假", "danger");
3182 3568 case AttendanceRecordStatusEnum.缺卡:
3183 3569 return ("missing", "缺卡", "danger");
  3570 + case AttendanceRecordStatusEnum.待考勤:
  3571 + return ("pending", "暂无打卡", "info");
3184 3572 case AttendanceRecordStatusEnum.旷工:
3185 3573 return ("absenteeism", "旷工", "danger");
3186 3574 default:
... ... @@ -3284,7 +3672,7 @@ namespace NCC.Extend
3284 3672 }
3285 3673  
3286 3674 /// <summary>
3287   - /// 为「请假」「补卡」等关联项补全打开流程详情所需字段(FLOW_TASK / FLOW_ENGINE)
  3675 + /// 为「请假」「补卡」「销假」等关联项补全打开流程详情所需字段(FLOW_TASK / FLOW_ENGINE)
3288 3676 /// </summary>
3289 3677 private async Task FillRelatedWorkflowFlowOpenAsync(List<RelatedWorkflowItem> list)
3290 3678 {
... ... @@ -3294,7 +3682,7 @@ namespace NCC.Extend
3294 3682 }
3295 3683  
3296 3684 foreach (var w in list.Where(x =>
3297   - (x.type == "请假" || x.type == "补卡") && !string.IsNullOrWhiteSpace(x.id)))
  3685 + (x.type == "请假" || x.type == "补卡" || x.type == "销假") && !string.IsNullOrWhiteSpace(x.id)))
3298 3686 {
3299 3687 if (!string.IsNullOrWhiteSpace(w.flowId)
3300 3688 && !string.IsNullOrWhiteSpace(w.flowCode)
... ... @@ -3362,6 +3750,21 @@ namespace NCC.Extend
3362 3750 return JsonConvert.SerializeObject(list);
3363 3751 }
3364 3752  
  3753 + private sealed class MonthReportUserLine
  3754 + {
  3755 + public string UserId { get; set; }
  3756 +
  3757 + public string EmployeeName { get; set; }
  3758 +
  3759 + public string StoreId { get; set; }
  3760 +
  3761 + public string StoreName { get; set; }
  3762 +
  3763 + public string AttendanceGroupId { get; set; }
  3764 +
  3765 + public string GroupName { get; set; }
  3766 + }
  3767 +
3365 3768 private sealed class PunchApplyTargetLine
3366 3769 {
3367 3770 public string date { get; set; }
... ...
绿纤uni-app/pages/attendance-punch/attendance-punch.vue
... ... @@ -18,29 +18,19 @@
18 18 <!-- 打卡方式:与小程序白卡片风格一致 -->
19 19 <view class="att-card att-seg-card">
20 20 <view class="att-seg">
21   - <view
22   - class="att-seg-item"
23   - :class="{ 'is-active': punchType === 1 }"
24   - @click="setPunchType(1)"
25   - >正常</view>
26   - <view
27   - class="att-seg-item"
28   - :class="{ 'is-active': punchType === 2 }"
29   - @click="setPunchType(2)"
30   - >外勤</view>
  21 + <view class="att-seg-item" :class="{ 'is-active': punchType === 1 }" @click="setPunchType(1)">正常
  22 + </view>
  23 + <view class="att-seg-item" :class="{ 'is-active': punchType === 2 }" @click="setPunchType(2)">外勤
  24 + </view>
31 25 </view>
32 26 </view>
33 27  
34 28 <!-- 单一大按钮:按顺序上班 → 下班 → 已完成 -->
35 29 <view class="att-card att-punch-card">
36   - <view
37   - class="att-big-btn"
38   - :class="{
39   - 'is-all-done': allPunchDone,
40   - 'is-disabled': !canPressMainButton
41   - }"
42   - @click="onMainPunch"
43   - >
  30 + <view class="att-big-btn" :class="{
  31 + 'is-all-done': allPunchDone,
  32 + 'is-disabled': !canPressMainButton
  33 + }" @click="onMainPunch">
44 34 <template v-if="allPunchDone">
45 35 <u-icon name="checkmark-circle-fill" color="#ffffff" size="52"></u-icon>
46 36 <text class="att-big-done-text">今日打卡已完成</text>
... ... @@ -61,17 +51,28 @@
61 51 <text class="att-loc-text">{{ locationText }}</text>
62 52 <text class="att-loc-tip">{{ fenceStatusText }}</text>
63 53 <view class="att-loc-actions">
64   - <text
65   - v-if="canViewFenceMap"
66   - class="att-loc-action att-loc-action--primary"
67   - @click.stop="openFenceMap"
68   - >查看打卡范围</text>
  54 + <text v-if="canViewFenceMap" class="att-loc-action att-loc-action--primary"
  55 + @click.stop="openFenceMap">查看打卡范围</text>
69 56 <text class="att-loc-action" @click.stop="refreshLocation">重新定位</text>
70 57 </view>
71 58 </view>
72 59 <u-icon name="arrow-right" color="#c8c9cc" size="18"></u-icon>
73 60 </view>
74 61  
  62 + <!-- #ifdef MP-WEIXIN -->
  63 + <view v-if="Number(punchType) === 1" class="att-card att-loc-card att-wifi-card">
  64 + <u-icon name="wifi" color="#43a047" size="22"></u-icon>
  65 + <view class="att-loc-main">
  66 + <text class="att-loc-text">{{ wifiMainText }}</text>
  67 + <text class="att-loc-tip">{{ wifiTipText }}</text>
  68 + <view class="att-loc-actions">
  69 + <text class="att-loc-action" @click.stop="refreshWifiInfo">刷新 Wi-Fi</text>
  70 + </view>
  71 + </view>
  72 + <u-icon name="arrow-right" color="#c8c9cc" size="18"></u-icon>
  73 + </view>
  74 + <!-- #endif -->
  75 +
75 76 <view class="att-card">
76 77 <view class="att-card-head">
77 78 <text class="att-card-title">今日记录</text>
... ... @@ -84,7 +85,9 @@
84 85 <view class="att-tl-body">
85 86 <view class="att-tl-row">
86 87 <text class="att-tl-name">上班</text>
87   - <text class="att-tl-time">{{ detail.punchIn && detail.punchIn.time ? detail.punchIn.time : '未打卡' }}</text>
  88 + <text class="att-tl-time">{{ detail.punchIn && detail.punchIn.time ? detail.punchIn.time
  89 + : '未打卡'
  90 + }}</text>
88 91 </view>
89 92 </view>
90 93 </view>
... ... @@ -93,7 +96,9 @@
93 96 <view class="att-tl-body">
94 97 <view class="att-tl-row">
95 98 <text class="att-tl-name">下班</text>
96   - <text class="att-tl-time">{{ detail.punchOut && detail.punchOut.time ? detail.punchOut.time : '未打卡' }}</text>
  99 + <text class="att-tl-time">{{ detail.punchOut && detail.punchOut.time ?
  100 + detail.punchOut.time : '未打卡'
  101 + }}</text>
97 102 </view>
98 103 </view>
99 104 </view>
... ... @@ -107,25 +112,13 @@
107 112 <view class="att-card att-more-card">
108 113 <view class="att-more-title">打卡照片(选填)</view>
109 114 <view class="att-upload-wrap">
110   - <u-upload
111   - :fileList="photoFileList"
112   - @afterRead="afterReadPhoto"
113   - @delete="deletePhoto"
114   - name="photo"
115   - :maxCount="1"
116   - accept="image"
117   - width="120"
118   - height="120"
119   - ></u-upload>
  115 + <u-upload :fileList="photoFileList" @afterRead="afterReadPhoto" @delete="deletePhoto" name="photo"
  116 + :maxCount="1" accept="image" width="120" height="120"></u-upload>
120 117 </view>
121 118 <template v-if="punchType === 2">
122 119 <view class="att-more-title att-more-title--second">外勤说明(选填)</view>
123   - <u-input
124   - v-model="remark"
125   - placeholder="请填写外勤事由(选填)"
126   - border="none"
127   - :customStyle="{ background: '#f1f8f4', padding: '16rpx 20rpx', borderRadius: '12rpx' }"
128   - ></u-input>
  120 + <u-input v-model="remark" placeholder="请填写外勤事由(选填)" border="none"
  121 + :customStyle="{ background: '#f1f8f4', padding: '16rpx 20rpx', borderRadius: '12rpx' }"></u-input>
129 122 </template>
130 123 </view>
131 124  
... ... @@ -140,728 +133,946 @@
140 133 </template>
141 134  
142 135 <script>
143   - import { isPointInPolygon, parseFencePolygons } from '@/service/attendance-fence.js'
144   -
145   - export default {
146   - data() {
147   - return {
148   - loading: false,
149   - submitting: false,
150   - userInfo: {},
151   - today: '',
152   - detail: null,
153   - punchConfig: null,
154   - storeFence: null,
155   - attendanceGroup: null,
156   - isInFence: null,
157   - punchType: 1,
158   - remark: '',
159   - longitude: null,
160   - latitude: null,
161   - address: '',
162   - fenceEnabled: false,
163   - locating: false,
164   - photoUrl: '',
165   - photoFileList: [],
166   - clockTime: '--:--:--',
167   - clockTimer: null
  136 +import { isPointInPolygon, parseFencePolygons } from '@/service/attendance-fence.js'
  137 +
  138 +export default {
  139 + data() {
  140 + return {
  141 + loading: false,
  142 + submitting: false,
  143 + userInfo: {},
  144 + today: '',
  145 + detail: null,
  146 + punchConfig: null,
  147 + storeFence: null,
  148 + attendanceGroup: null,
  149 + isInFence: null,
  150 + punchType: 1,
  151 + remark: '',
  152 + longitude: null,
  153 + latitude: null,
  154 + address: '',
  155 + fenceEnabled: false,
  156 + locating: false,
  157 + photoUrl: '',
  158 + photoFileList: [],
  159 + clockTime: '--:--:--',
  160 + clockTimer: null,
  161 + wifiSSID: '',
  162 + wifiBSSID: '',
  163 + wifiLoading: false,
  164 + wifiError: ''
  165 + }
  166 + },
  167 + computed: {
  168 + todayText() {
  169 + if (!this.today) return ''
  170 + const [y, m, d] = this.today.split('-')
  171 + return `${y}年${Number(m)}月${Number(d)}日`
  172 + },
  173 + weekdayText() {
  174 + if (!this.today) return ''
  175 + const w = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
  176 + const [y, m, d] = this.today.split('-').map(Number)
  177 + const dt = new Date(y, m - 1, d)
  178 + return w[dt.getDay()]
  179 + },
  180 + ruleTimeText() {
  181 + if (this.attendanceGroup && this.attendanceGroup.workStartTime && this.attendanceGroup.workEndTime) {
  182 + return `${this.attendanceGroup.workStartTime} - ${this.attendanceGroup.workEndTime}`
  183 + }
  184 + if (this.detail && this.detail.workStartTime && this.detail.workEndTime) {
  185 + return `${this.detail.workStartTime} - ${this.detail.workEndTime}`
168 186 }
  187 + return ''
169 188 },
170   - computed: {
171   - todayText() {
172   - if (!this.today) return ''
173   - const [y, m, d] = this.today.split('-')
174   - return `${y}年${Number(m)}月${Number(d)}日`
175   - },
176   - weekdayText() {
177   - if (!this.today) return ''
178   - const w = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
179   - const [y, m, d] = this.today.split('-').map(Number)
180   - const dt = new Date(y, m - 1, d)
181   - return w[dt.getDay()]
182   - },
183   - ruleTimeText() {
184   - if (this.attendanceGroup && this.attendanceGroup.workStartTime && this.attendanceGroup.workEndTime) {
185   - return `${this.attendanceGroup.workStartTime} - ${this.attendanceGroup.workEndTime}`
186   - }
187   - if (this.detail && this.detail.workStartTime && this.detail.workEndTime) {
188   - return `${this.detail.workStartTime} - ${this.detail.workEndTime}`
189   - }
190   - return ''
191   - },
192   - displayName() {
193   - return this.detail && this.detail.employeeName
194   - ? this.detail.employeeName
195   - : (this.userInfo.userName || this.userInfo.realName || '员工')
196   - },
197   - locationText() {
198   - if (this.locating) return '正在获取位置…'
199   - if (this.address) return this.address
200   - if (this.longitude != null && this.latitude != null) {
201   - return `已定位 · ${Number(this.latitude).toFixed(4)}, ${Number(this.longitude).toFixed(4)}`
  189 + displayName() {
  190 + return this.detail && this.detail.employeeName
  191 + ? this.detail.employeeName
  192 + : (this.userInfo.userName || this.userInfo.realName || '员工')
  193 + },
  194 + locationText() {
  195 + if (this.locating) return '正在获取位置…'
  196 + if (this.address) return this.address
  197 + if (this.longitude != null && this.latitude != null) {
  198 + return `已定位 · ${Number(this.latitude).toFixed(4)}, ${Number(this.longitude).toFixed(4)}`
  199 + }
  200 + return '点击获取定位,用于考勤校验'
  201 + },
  202 + requireFenceCheck() {
  203 + const sf = this.storeFence
  204 + if (!sf) return false
  205 + const rf = sf.requireFenceCheck != null ? sf.requireFenceCheck : sf.RequireFenceCheck
  206 + return rf === true || rf === 1 || rf === '1'
  207 + },
  208 + requireWifiCheck() {
  209 + const sf = this.storeFence
  210 + if (!sf) return false
  211 + const rw = sf.requireWifiCheck != null ? sf.requireWifiCheck : sf.RequireWifiCheck
  212 + return rw === true || rw === 1 || rw === '1'
  213 + },
  214 + /** 门店是否已配置 Wi-Fi 成对白名单(与接口 attendanceWifiPairs 一致) */
  215 + wifiPairRulesCount() {
  216 + const sf = this.storeFence
  217 + if (!sf) return 0
  218 + const raw = sf.attendanceWifiPairs != null ? sf.attendanceWifiPairs : sf.AttendanceWifiPairs
  219 + return Array.isArray(raw) ? raw.length : 0
  220 + },
  221 + wifiVerifyPairStrict() {
  222 + const sf = this.storeFence
  223 + if (!sf) return false
  224 + const v = sf.attendanceWifiVerifyPair != null ? sf.attendanceWifiVerifyPair : sf.AttendanceWifiVerifyPair
  225 + return Number(v) === 1
  226 + },
  227 + wifiMatchesStore() {
  228 + const sf = this.storeFence
  229 + const rawPairs = sf && (sf.attendanceWifiPairs != null ? sf.attendanceWifiPairs : sf.AttendanceWifiPairs)
  230 + const pairs = Array.isArray(rawPairs) ? rawPairs : []
  231 + const curSsid = (this.wifiSSID || '').trim()
  232 + const curB = this.normalizeAttendanceBssid(this.wifiBSSID)
  233 + const hasClientBssid = !!curB
  234 + if (pairs.length > 0) {
  235 + const strict = this.wifiVerifyPairStrict
  236 + if (strict) {
  237 + if (hasClientBssid) {
  238 + // 与后端一致:有 BSSID 时以路由器 MAC 为准(避免 iOS/微信 SSID 为空或不准确)
  239 + return pairs.some((p) => {
  240 + const pb = this.normalizeAttendanceBssid(p.bssid)
  241 + return !!pb && pb === curB
  242 + })
  243 + }
  244 + if (this.isInFence !== true) return false
  245 + if (!curSsid) return false
  246 + return pairs.some((p) => {
  247 + const ps = String(p.ssid || '').trim()
  248 + return !!ps && ps.toLowerCase() === curSsid.toLowerCase()
  249 + })
202 250 }
203   - return '点击获取定位,用于考勤校验'
204   - },
205   - fenceStatusText() {
206   - if (this.locating) return '正在校验打卡范围…'
207   - if (Number(this.punchType) === 2) return '外勤打卡不受门店围栏限制'
208   - if (!this.fenceEnabled) return '当前门店未配置围栏,正常打卡不受范围限制'
  251 + return pairs.some((p) => {
  252 + const ps = String(p.ssid || '').trim()
  253 + const pb = this.normalizeAttendanceBssid(p.bssid)
  254 + const ssidHit =
  255 + !!ps && !!curSsid && ps.toLowerCase() === curSsid.toLowerCase()
  256 + const bssidHit = !!pb && pb === curB
  257 + return ssidHit || bssidHit
  258 + })
  259 + }
  260 + return false
  261 + },
  262 + /** 正常上班时是否满足门店「围栏 / Wi-Fi / 或二者择一」策略(与后端一致) */
  263 + normalPunchLocationOk() {
  264 + if (Number(this.punchType) !== 1) return true
  265 + const rf = this.requireFenceCheck
  266 + const rw = this.requireWifiCheck
  267 + if (!rf && !rw) return true
  268 + const fenceOk = !rf || this.isInFence === true
  269 + const wifiOk = !rw || this.wifiMatchesStore
  270 + if (rf && rw) return fenceOk || wifiOk
  271 + if (rf) return fenceOk
  272 + return wifiOk
  273 + },
  274 + fenceStatusText() {
  275 + if (this.locating) return '正在校验位置…'
  276 + if (Number(this.punchType) === 2) return '外勤打卡不受门店位置与 Wi-Fi 限制'
  277 + const rf = this.requireFenceCheck
  278 + const rw = this.requireWifiCheck
  279 + if (!rf && !rw) return '当前门店未要求范围或 Wi-Fi,正常打卡可直接进行'
  280 + const fenceLine = rf
  281 + ? (this.longitude == null || this.latitude == null
  282 + ? '需定位后判断是否在打卡范围内'
  283 + : (this.isInFence === true ? '已在打卡范围内' : '不在打卡范围内'))
  284 + : ''
  285 + const hasPairs = this.wifiPairRulesCount > 0
  286 + const wifiLine = rw
  287 + ? (!hasPairs
  288 + ? '门店已要求 Wi-Fi 打卡,但未配置网络白名单,请联系管理员在门店资料中维护'
  289 + : (this.wifiMatchesStore
  290 + ? '已匹配门店 Wi-Fi 规则'
  291 + : '未匹配门店 Wi-Fi 规则(开启「对应」且无 BSSID 时需同时在围栏内且 SSID 正确)'))
  292 + : ''
  293 + if (rf && rw) {
  294 + return `门店要求:范围或 Wi-Fi 满足其一即可。${fenceLine};${wifiLine}`
  295 + }
  296 + if (rf) {
209 297 if (this.longitude == null || this.latitude == null) return '点击更新定位后校验是否在门店范围内'
210 298 if (this.isInFence === true) return '当前位于门店打卡范围内,可正常上下班打卡'
211 299 if (this.isInFence === false) return '当前不在门店打卡范围内,正常打卡不可用'
212 300 return '定位完成后自动校验门店围栏范围'
213   - },
214   - punchInDone() {
215   - return !!(this.detail && this.detail.punchIn && this.detail.punchIn.time)
216   - },
217   - punchOutDone() {
218   - return !!(this.detail && this.detail.punchOut && this.detail.punchOut.time)
219   - },
220   - /** 下一步应打卡方向:1 上班 → 2 下班;都完成则为 null */
221   - nextPunchDirection() {
222   - if (!this.userInfo.userId) return null
223   - if (!this.punchInDone) return 1
224   - if (!this.punchOutDone) return 2
225   - return null
226   - },
227   - allPunchDone() {
228   - return this.punchInDone && this.punchOutDone
229   - },
230   - mainButtonTitle() {
231   - if (this.allPunchDone) return '今日打卡已完成'
232   - if (this.nextPunchDirection === 1) return '上班打卡'
233   - if (this.nextPunchDirection === 2) return '下班打卡'
234   - return '打卡'
235   - },
236   - mainButtonSub() {
237   - if (this.allPunchDone) return ''
238   - if (this.nextPunchDirection === 1) return '上班签到'
239   - if (this.nextPunchDirection === 2) return '下班签退'
240   - return ''
241   - },
242   - canPressMainButton() {
243   - if (this.loading || this.submitting || this.locating) return false
244   - if (!this.userInfo.userId) return false
245   - if (this.nextPunchDirection == null) return false
246   - if (Number(this.punchType) === 1 && this.fenceEnabled && this.isInFence !== true) return false
247   - return true
248   - },
249   - canViewFenceMap() {
250   - return !!(
251   - (this.storeFence && this.storeFence.hasFence && this.storeFence.fencePolygons) ||
252   - (this.storeFence && this.storeFence.longitude != null && this.storeFence.latitude != null) ||
253   - (this.longitude != null && this.latitude != null)
254   - )
255 301 }
  302 + return wifiLine || '请连接门店指定 Wi-Fi 后再试'
256 303 },
257   - onLoad() {
258   - this.userInfo = uni.getStorageSync('userInfo') || {}
259   - this.today = this.formatDate(new Date())
  304 + punchInDone() {
  305 + return !!(this.detail && this.detail.punchIn && this.detail.punchIn.time)
260 306 },
261   - onShow() {
262   - this.userInfo = uni.getStorageSync('userInfo') || {}
263   - this.startClock()
264   - this.initializePage()
  307 + punchOutDone() {
  308 + return !!(this.detail && this.detail.punchOut && this.detail.punchOut.time)
265 309 },
266   - onHide() {
267   - this.stopClock()
  310 + /** 下一步应打卡方向:1 上班 → 2 下班;都完成则为 null */
  311 + nextPunchDirection() {
  312 + if (!this.userInfo.userId) return null
  313 + if (!this.punchInDone) return 1
  314 + if (!this.punchOutDone) return 2
  315 + return null
268 316 },
269   - onUnload() {
270   - this.stopClock()
  317 + allPunchDone() {
  318 + return this.punchInDone && this.punchOutDone
271 319 },
272   - onPullDownRefresh() {
273   - this.initializePage().finally(() => {
274   - uni.stopPullDownRefresh()
275   - })
  320 + mainButtonTitle() {
  321 + if (this.allPunchDone) return '今日打卡已完成'
  322 + if (this.nextPunchDirection === 1) return '上班打卡'
  323 + if (this.nextPunchDirection === 2) return '下班打卡'
  324 + return '打卡'
276 325 },
277   - methods: {
278   - /** 切回「正常」时只清空外勤说明;照片正常/外勤共用,保留 */
279   - setPunchType(type) {
280   - this.punchType = type
281   - if (type === 1) {
282   - this.remark = ''
283   - }
284   - },
285   - async initializePage() {
286   - await this.loadPunchConfig()
287   - await this.refreshLocation()
288   - await this.loadDetail()
289   - },
290   - onMainPunch() {
291   - if (!this.nextPunchDirection) return
292   - if (Number(this.punchType) === 1 && this.fenceEnabled && this.isInFence !== true) {
293   - uni.showToast({ title: '当前不在门店打卡范围内,无法正常打卡', icon: 'none' })
294   - return
  326 + mainButtonSub() {
  327 + if (this.allPunchDone) return ''
  328 + if (this.nextPunchDirection === 1) return '上班签到'
  329 + if (this.nextPunchDirection === 2) return '下班签退'
  330 + return ''
  331 + },
  332 + canPressMainButton() {
  333 + if (this.loading || this.submitting || this.locating) return false
  334 + if (!this.userInfo.userId) return false
  335 + if (this.nextPunchDirection == null) return false
  336 + if (Number(this.punchType) === 1 && !this.normalPunchLocationOk) return false
  337 + return true
  338 + },
  339 + canViewFenceMap() {
  340 + return !!(
  341 + (this.storeFence && this.storeFence.hasFence && this.storeFence.fencePolygons) ||
  342 + (this.storeFence && this.storeFence.longitude != null && this.storeFence.latitude != null) ||
  343 + (this.longitude != null && this.latitude != null)
  344 + )
  345 + },
  346 + wifiMainText() {
  347 + if (this.wifiLoading) return '正在获取 Wi-Fi 信息…'
  348 + if (this.wifiError) return this.wifiError
  349 + if (this.wifiSSID) return `当前网络:${this.wifiSSID}`
  350 + return '未获取到 Wi-Fi 名称'
  351 + },
  352 + wifiTipText() {
  353 + if (this.wifiLoading) return '用于核对是否在门店网络环境'
  354 + if (this.wifiError) {
  355 + if (this.wifiError.indexOf('模拟器') !== -1 || this.wifiError.indexOf('真机') !== -1) {
  356 + return 'Wi-Fi 由系统提供,开发者工具无此能力'
295 357 }
296   - if (!this.canPressMainButton) return
297   - this.doPunch(this.nextPunchDirection)
298   - },
299   - startClock() {
300   - this.tickClock()
301   - this.clockTimer = setInterval(this.tickClock, 1000)
302   - },
303   - stopClock() {
304   - if (this.clockTimer) {
305   - clearInterval(this.clockTimer)
306   - this.clockTimer = null
  358 + return '可点击「刷新 Wi-Fi」重试;部分机型需开启定位权限'
  359 + }
  360 + if (Number(this.punchType) === 1 && this.requireWifiCheck) {
  361 + if (this.wifiPairRulesCount === 0) {
  362 + return '门店已开启 Wi-Fi 校验,白名单未配置,请联系管理员'
307 363 }
308   - },
309   - tickClock() {
310   - const now = new Date()
311   - const h = `${now.getHours()}`.padStart(2, '0')
312   - const m = `${now.getMinutes()}`.padStart(2, '0')
313   - const s = `${now.getSeconds()}`.padStart(2, '0')
314   - this.clockTime = `${h}:${m}:${s}`
315   - },
316   - formatDate(d) {
317   - const y = d.getFullYear()
318   - const m = `${d.getMonth() + 1}`.padStart(2, '0')
319   - const day = `${d.getDate()}`.padStart(2, '0')
320   - return `${y}-${m}-${day}`
321   - },
322   - async loadDetail() {
323   - if (!this.userInfo.userId) {
324   - return
  364 + const strict = this.wifiVerifyPairStrict
  365 + if (this.wifiMatchesStore) {
  366 + return strict ? '已满足门店 Wi-Fi(含对应校验)' : '已命中门店 Wi-Fi 白名单'
325 367 }
326   - this.loading = true
327   - try {
328   - const res = await this.API.getAttendanceDetail({
329   - userId: this.userInfo.userId,
330   - attendanceDate: this.today
331   - })
332   - if (res && Number(res.code) === 200 && res.data) {
333   - this.detail = res.data
334   - } else {
335   - this.detail = null
336   - }
337   - } catch (e) {
  368 + return strict
  369 + ? '未满足:若已开「对应」校验,请连门店 AP 或进入围栏且 SSID 正确'
  370 + : '未命中白名单(可核对 SSID/BSSID;与围栏组合时满足其一即可)'
  371 + }
  372 + if (this.wifiBSSID) return `BSSID:${this.wifiBSSID}`
  373 + return '名称由系统返回;iOS 常可稳定获取 BSSID'
  374 + }
  375 + },
  376 + onLoad() {
  377 + this.userInfo = uni.getStorageSync('userInfo') || {}
  378 + this.today = this.formatDate(new Date())
  379 + },
  380 + onShow() {
  381 + this.userInfo = uni.getStorageSync('userInfo') || {}
  382 + this.startClock()
  383 + this.initializePage()
  384 + },
  385 + onHide() {
  386 + this.stopClock()
  387 + },
  388 + onUnload() {
  389 + this.stopClock()
  390 + },
  391 + onPullDownRefresh() {
  392 + this.initializePage().finally(() => {
  393 + uni.stopPullDownRefresh()
  394 + })
  395 + },
  396 + methods: {
  397 + /** 与后端 NormalizeAttendanceBssid 一致:连字符统一为冒号、小写 */
  398 + normalizeAttendanceBssid(s) {
  399 + if (!s) return ''
  400 + let t = String(s).trim().replace(/-/g, ':').replace(/\s+/g, '').toLowerCase()
  401 + if (t.indexOf(':') < 0 && t.length === 12 && /^[0-9a-f]{12}$/.test(t)) {
  402 + t = t.match(/.{2}/g).join(':')
  403 + }
  404 + return t
  405 + },
  406 + /** 切回「正常」时只清空外勤说明;照片正常/外勤共用,保留 */
  407 + setPunchType(type) {
  408 + this.punchType = type
  409 + if (Number(type) === 1) {
  410 + this.remark = ''
  411 + // #ifdef MP-WEIXIN
  412 + this.refreshWifiInfo()
  413 + // #endif
  414 + }
  415 + },
  416 + async initializePage() {
  417 + await this.loadPunchConfig()
  418 + const wifiTask =
  419 + Number(this.punchType) === 1 ? this.refreshWifiInfo() : Promise.resolve()
  420 + await Promise.all([this.refreshLocation(), wifiTask])
  421 + await this.loadDetail()
  422 + },
  423 + onMainPunch() {
  424 + if (!this.nextPunchDirection) return
  425 + if (Number(this.punchType) === 1 && !this.normalPunchLocationOk) {
  426 + uni.showToast({
  427 + title: '未满足门店要求(需在范围内或指定 Wi-Fi/BSSID),可改用外勤',
  428 + icon: 'none'
  429 + })
  430 + return
  431 + }
  432 + if (!this.canPressMainButton) return
  433 + this.doPunch(this.nextPunchDirection)
  434 + },
  435 + startClock() {
  436 + this.tickClock()
  437 + this.clockTimer = setInterval(this.tickClock, 1000)
  438 + },
  439 + stopClock() {
  440 + if (this.clockTimer) {
  441 + clearInterval(this.clockTimer)
  442 + this.clockTimer = null
  443 + }
  444 + },
  445 + tickClock() {
  446 + const now = new Date()
  447 + const h = `${now.getHours()}`.padStart(2, '0')
  448 + const m = `${now.getMinutes()}`.padStart(2, '0')
  449 + const s = `${now.getSeconds()}`.padStart(2, '0')
  450 + this.clockTime = `${h}:${m}:${s}`
  451 + },
  452 + formatDate(d) {
  453 + const y = d.getFullYear()
  454 + const m = `${d.getMonth() + 1}`.padStart(2, '0')
  455 + const day = `${d.getDate()}`.padStart(2, '0')
  456 + return `${y}-${m}-${day}`
  457 + },
  458 + async loadDetail() {
  459 + if (!this.userInfo.userId) {
  460 + return
  461 + }
  462 + this.loading = true
  463 + try {
  464 + const res = await this.API.getAttendanceDetail({
  465 + userId: this.userInfo.userId,
  466 + attendanceDate: this.today
  467 + })
  468 + if (res && Number(res.code) === 200 && res.data) {
  469 + this.detail = res.data
  470 + } else {
338 471 this.detail = null
339   - } finally {
340   - this.loading = false
341 472 }
342   - },
343   - async loadPunchConfig() {
344   - if (!this.userInfo.userId) {
  473 + } catch (e) {
  474 + this.detail = null
  475 + } finally {
  476 + this.loading = false
  477 + }
  478 + },
  479 + async loadPunchConfig() {
  480 + if (!this.userInfo.userId) {
  481 + this.punchConfig = null
  482 + this.storeFence = null
  483 + this.attendanceGroup = null
  484 + this.fenceEnabled = false
  485 + this.isInFence = null
  486 + return
  487 + }
  488 + try {
  489 + const res = await this.API.getCurrentAttendancePunchConfig({})
  490 + if (res && Number(res.code) === 200 && res.data) {
  491 + this.punchConfig = res.data
  492 + this.storeFence = res.data.storeFence || null
  493 + this.attendanceGroup = res.data.attendanceGroup || null
  494 + this.fenceEnabled = !!(this.storeFence && this.storeFence.hasFence && this.storeFence.fencePolygons)
  495 + this.evaluateFenceRange()
  496 + } else {
345 497 this.punchConfig = null
346 498 this.storeFence = null
347 499 this.attendanceGroup = null
348 500 this.fenceEnabled = false
349 501 this.isInFence = null
350   - return
351 502 }
  503 + } catch (e) {
  504 + this.punchConfig = null
  505 + this.storeFence = null
  506 + this.attendanceGroup = null
  507 + this.fenceEnabled = false
  508 + this.isInFence = null
  509 + }
  510 + },
  511 + refreshWifiInfo() {
  512 + return new Promise((resolve) => {
  513 + // #ifndef MP-WEIXIN
  514 + resolve()
  515 + return
  516 + // #endif
  517 + // #ifdef MP-WEIXIN
352 518 try {
353   - const res = await this.API.getCurrentAttendancePunchConfig({})
354   - if (res && Number(res.code) === 200 && res.data) {
355   - this.punchConfig = res.data
356   - this.storeFence = res.data.storeFence || null
357   - this.attendanceGroup = res.data.attendanceGroup || null
358   - this.fenceEnabled = !!(this.storeFence && this.storeFence.hasFence && this.storeFence.fencePolygons)
359   - this.evaluateFenceRange()
360   - } else {
361   - this.punchConfig = null
362   - this.storeFence = null
363   - this.attendanceGroup = null
364   - this.fenceEnabled = false
365   - this.isInFence = null
  519 + const sys = uni.getSystemInfoSync()
  520 + if (sys && sys.platform === 'devtools') {
  521 + this.wifiLoading = false
  522 + this.wifiSSID = ''
  523 + this.wifiBSSID = ''
  524 + this.wifiError = '开发者工具模拟器不支持 Wi-Fi,请用真机预览或扫码调试'
  525 + resolve()
  526 + return
366 527 }
367   - } catch (e) {
368   - this.punchConfig = null
369   - this.storeFence = null
370   - this.attendanceGroup = null
371   - this.fenceEnabled = false
372   - this.isInFence = null
373   - }
374   - },
375   - refreshLocation() {
376   - this.locating = true
377   - return new Promise((resolve) => {
378   - uni.getLocation({
379   - type: 'gcj02',
380   - isHighAccuracy: true,
381   - success: (res) => {
382   - this.longitude = res.longitude
383   - this.latitude = res.latitude
384   - this.address = ''
385   - this.evaluateFenceRange()
386   - this.locating = false
387   - resolve()
388   - },
389   - fail: () => {
390   - this.locating = false
391   - this.isInFence = null
392   - uni.showToast({ title: '定位失败,请检查系统定位权限', icon: 'none' })
393   - resolve()
  528 + } catch (e) { }
  529 + this.wifiLoading = true
  530 + this.wifiError = ''
  531 + uni.startWifi({
  532 + success: () => {
  533 + uni.getConnectedWifi({
  534 + success: (res) => {
  535 + const w = res && res.wifi
  536 + this.wifiSSID = w && w.SSID ? String(w.SSID) : ''
  537 + this.wifiBSSID = w && w.BSSID ? String(w.BSSID) : ''
  538 + this.wifiLoading = false
  539 + if (!this.wifiSSID && !this.wifiBSSID) {
  540 + this.wifiError = '未连接 Wi-Fi'
  541 + }
  542 + resolve()
  543 + },
  544 + fail: (err) => {
  545 + this.wifiLoading = false
  546 + const msg = (err && err.errMsg) ? String(err.errMsg) : ''
  547 + if (msg.indexOf('auth deny') !== -1 || msg.indexOf('authorize') !== -1) {
  548 + this.wifiError = '需授权获取 Wi-Fi 信息'
  549 + } else if (msg.indexOf('not connected') !== -1 || (err && Number(err.errCode) === 12002)) {
  550 + this.wifiError = '当前未连接 Wi-Fi'
  551 + } else {
  552 + this.wifiError = '无法获取 Wi-Fi 信息'
  553 + }
  554 + this.wifiSSID = ''
  555 + this.wifiBSSID = ''
  556 + resolve()
  557 + }
  558 + })
  559 + },
  560 + fail: (err) => {
  561 + this.wifiLoading = false
  562 + this.wifiSSID = ''
  563 + this.wifiBSSID = ''
  564 + const msg = (err && err.errMsg) ? String(err.errMsg) : ''
  565 + if (msg.indexOf('devtools') !== -1 || msg.indexOf('not support') !== -1) {
  566 + this.wifiError = '当前环境不支持 Wi-Fi,请用真机预览'
  567 + } else {
  568 + this.wifiError = '无法启动 Wi-Fi 模块,请用真机重试'
394 569 }
395   - })
396   - })
397   - },
398   - openFenceMap() {
399   - if (!this.canViewFenceMap) {
400   - uni.showToast({ title: '暂无可展示的打卡范围', icon: 'none' })
401   - return
402   - }
403   - const query = []
404   - if (this.longitude != null) query.push(`longitude=${encodeURIComponent(this.longitude)}`)
405   - if (this.latitude != null) query.push(`latitude=${encodeURIComponent(this.latitude)}`)
406   - if (this.address) query.push(`address=${encodeURIComponent(this.address)}`)
407   - if (this.isInFence !== null) query.push(`isInFence=${this.isInFence ? 1 : 0}`)
408   - uni.navigateTo({
409   - url: `/pages/attendance-fence-map/attendance-fence-map${query.length ? `?${query.join('&')}` : ''}`
  570 + resolve()
  571 + }
410 572 })
411   - },
412   - evaluateFenceRange() {
413   - if (!this.fenceEnabled) {
414   - this.isInFence = true
415   - return
416   - }
417   - if (this.longitude == null || this.latitude == null) {
418   - this.isInFence = null
419   - return
420   - }
421   - const polygons = parseFencePolygons(this.storeFence && this.storeFence.fencePolygons)
422   - if (!polygons.length) {
423   - this.isInFence = true
424   - return
425   - }
426   - this.isInFence = polygons.some((polygon) => this.isPointInPolygon(this.longitude, this.latitude, polygon))
427   - },
428   - isPointInPolygon(lng, lat, polygon) {
429   - return isPointInPolygon(lng, lat, polygon)
430   - },
431   - async afterReadPhoto(event) {
432   - const lists = [].concat(event.file)
433   - const file = lists[0]
434   - let fileObj = file
435   - if (file.url && !file.path && !file.tempFilePath) {
436   - fileObj = { tempFilePath: file.url }
437   - }
438   - try {
439   - uni.showLoading({ title: '上传中' })
440   - const result = await this.API.uploadFile(fileObj)
441   - uni.hideLoading()
442   - if (result && result.code === 200 && result.data && result.data.url) {
443   - this.photoUrl = result.data.url
444   - this.photoFileList = [{
445   - url: result.data.url,
446   - status: 'success'
447   - }]
448   - } else {
449   - uni.showToast({ title: (result && result.msg) || '上传失败', icon: 'none' })
  573 + // #endif
  574 + })
  575 + },
  576 + refreshLocation() {
  577 + this.locating = true
  578 + return new Promise((resolve) => {
  579 + uni.getLocation({
  580 + type: 'gcj02',
  581 + isHighAccuracy: true,
  582 + success: (res) => {
  583 + this.longitude = res.longitude
  584 + this.latitude = res.latitude
  585 + this.address = ''
  586 + this.evaluateFenceRange()
  587 + this.locating = false
  588 + resolve()
  589 + },
  590 + fail: () => {
  591 + this.locating = false
  592 + this.isInFence = null
  593 + uni.showToast({ title: '定位失败,请检查系统定位权限', icon: 'none' })
  594 + resolve()
450 595 }
451   - } catch (err) {
452   - uni.hideLoading()
453   - uni.showToast({ title: '上传失败', icon: 'none' })
454   - }
455   - },
456   - deletePhoto() {
457   - this.photoUrl = ''
458   - this.photoFileList = []
459   - },
460   - async doPunch(direction) {
461   - if (!this.userInfo.userId) {
462   - uni.showToast({ title: '请先登录', icon: 'none' })
463   - return
  596 + })
  597 + })
  598 + },
  599 + openFenceMap() {
  600 + if (!this.canViewFenceMap) {
  601 + uni.showToast({ title: '暂无可展示的打卡范围', icon: 'none' })
  602 + return
  603 + }
  604 + const query = []
  605 + if (this.longitude != null) query.push(`longitude=${encodeURIComponent(this.longitude)}`)
  606 + if (this.latitude != null) query.push(`latitude=${encodeURIComponent(this.latitude)}`)
  607 + if (this.address) query.push(`address=${encodeURIComponent(this.address)}`)
  608 + if (this.isInFence !== null) query.push(`isInFence=${this.isInFence ? 1 : 0}`)
  609 + uni.navigateTo({
  610 + url: `/pages/attendance-fence-map/attendance-fence-map${query.length ? `?${query.join('&')}` : ''}`
  611 + })
  612 + },
  613 + evaluateFenceRange() {
  614 + if (!this.fenceEnabled) {
  615 + this.isInFence = true
  616 + return
  617 + }
  618 + if (this.longitude == null || this.latitude == null) {
  619 + this.isInFence = null
  620 + return
  621 + }
  622 + const polygons = parseFencePolygons(this.storeFence && this.storeFence.fencePolygons)
  623 + if (!polygons.length) {
  624 + this.isInFence = true
  625 + return
  626 + }
  627 + this.isInFence = polygons.some((polygon) => this.isPointInPolygon(this.longitude, this.latitude, polygon))
  628 + },
  629 + isPointInPolygon(lng, lat, polygon) {
  630 + return isPointInPolygon(lng, lat, polygon)
  631 + },
  632 + async afterReadPhoto(event) {
  633 + const lists = [].concat(event.file)
  634 + const file = lists[0]
  635 + let fileObj = file
  636 + if (file.url && !file.path && !file.tempFilePath) {
  637 + fileObj = { tempFilePath: file.url }
  638 + }
  639 + try {
  640 + uni.showLoading({ title: '上传中' })
  641 + const result = await this.API.uploadFile(fileObj)
  642 + uni.hideLoading()
  643 + if (result && result.code === 200 && result.data && result.data.url) {
  644 + this.photoUrl = result.data.url
  645 + this.photoFileList = [{
  646 + url: result.data.url,
  647 + status: 'success'
  648 + }]
  649 + } else {
  650 + uni.showToast({ title: (result && result.msg) || '上传失败', icon: 'none' })
464 651 }
  652 + } catch (err) {
  653 + uni.hideLoading()
  654 + uni.showToast({ title: '上传失败', icon: 'none' })
  655 + }
  656 + },
  657 + deletePhoto() {
  658 + this.photoUrl = ''
  659 + this.photoFileList = []
  660 + },
  661 + async doPunch(direction) {
  662 + if (!this.userInfo.userId) {
  663 + uni.showToast({ title: '请先登录', icon: 'none' })
  664 + return
  665 + }
  666 + const isNormal = Number(this.punchType) === 1
  667 + const needGps =
  668 + !isNormal ||
  669 + !!(this.storeFence && this.storeFence.requireFenceCheck)
  670 + if (needGps) {
465 671 if (this.longitude == null || this.latitude == null) {
466 672 await this.refreshLocation()
467   - if (this.longitude == null || this.latitude == null) {
468   - uni.showToast({ title: '请先完成定位', icon: 'none' })
469   - return
470   - }
471 673 }
472   - const isFieldWork = Number(this.punchType) === 2
473   - const payload = {
474   - userId: this.userInfo.userId,
475   - punchDirection: direction,
476   - punchType: Number(this.punchType),
477   - longitude: this.longitude,
478   - latitude: this.latitude,
479   - address: this.address || this.locationText,
480   - photoUrl: this.photoUrl || undefined,
481   - remark: isFieldWork ? ((this.remark || '').trim() || undefined) : undefined
  674 + if (this.longitude == null || this.latitude == null) {
  675 + uni.showToast({ title: '请先完成定位', icon: 'none' })
  676 + return
482 677 }
483   - this.submitting = true
484   - try {
485   - const res = await this.API.submitAttendancePunch(payload)
486   - if (res && Number(res.code) === 200) {
487   - uni.showToast({ title: '打卡成功', icon: 'success' })
488   - this.remark = ''
489   - this.photoUrl = ''
490   - this.photoFileList = []
491   - await this.loadDetail()
492   - } else {
493   - uni.showToast({ title: (res && (res.msg || res.message)) || '打卡失败', icon: 'none' })
494   - }
495   - } catch (e) {
496   - uni.showToast({ title: '打卡失败', icon: 'none' })
497   - } finally {
498   - this.submitting = false
  678 + } else if (isNormal && this.longitude == null) {
  679 + await this.refreshLocation()
  680 + }
  681 + const isFieldWork = Number(this.punchType) === 2
  682 + const payload = {
  683 + userId: this.userInfo.userId,
  684 + punchDirection: direction,
  685 + punchType: Number(this.punchType),
  686 + longitude: this.longitude != null ? this.longitude : undefined,
  687 + latitude: this.latitude != null ? this.latitude : undefined,
  688 + address: this.address || this.locationText,
  689 + wifiSsid: (this.wifiSSID || '').trim() || undefined,
  690 + wifiBssid: (this.wifiBSSID || '').trim() || undefined,
  691 + photoUrl: this.photoUrl || undefined,
  692 + remark: isFieldWork ? ((this.remark || '').trim() || undefined) : undefined
  693 + }
  694 + this.submitting = true
  695 + try {
  696 + const res = await this.API.submitAttendancePunch(payload)
  697 + if (res && Number(res.code) === 200) {
  698 + uni.showToast({ title: '打卡成功', icon: 'success' })
  699 + this.remark = ''
  700 + this.photoUrl = ''
  701 + this.photoFileList = []
  702 + await this.loadDetail()
  703 + } else {
  704 + uni.showToast({ title: (res && (res.msg || res.message)) || '打卡失败', icon: 'none' })
499 705 }
  706 + } catch (e) {
  707 + uni.showToast({ title: '打卡失败', icon: 'none' })
  708 + } finally {
  709 + this.submitting = false
500 710 }
501 711 }
502 712 }
  713 +}
503 714 </script>
504 715  
505 716 <style scoped lang="scss">
506   - /* 与 pages/index 首页:浅绿底 #e8f5e9、渐变顶栏、主色 #43a047 */
507   - .att-page {
508   - min-height: 100vh;
509   - background: #e8f5e9;
510   - padding-bottom: constant(safe-area-inset-bottom);
511   - padding-bottom: env(safe-area-inset-bottom);
512   - box-sizing: border-box;
513   - }
  717 +/* 与 pages/index 首页:浅绿底 #e8f5e9、渐变顶栏、主色 #43a047 */
  718 +.att-page {
  719 + min-height: 100vh;
  720 + background: #e8f5e9;
  721 + padding-bottom: constant(safe-area-inset-bottom);
  722 + padding-bottom: env(safe-area-inset-bottom);
  723 + box-sizing: border-box;
  724 +}
514 725  
515   - .att-header {
516   - background: linear-gradient(120deg, #43e97b 0%, #38f9d7 100%);
517   - padding: 40rpx 32rpx 48rpx;
518   - text-align: center;
519   - box-shadow: 0 4rpx 24rpx 0 rgba(67, 233, 123, 0.12);
520   - }
  726 +.att-header {
  727 + background: linear-gradient(120deg, #43e97b 0%, #38f9d7 100%);
  728 + padding: 40rpx 32rpx 48rpx;
  729 + text-align: center;
  730 + box-shadow: 0 4rpx 24rpx 0 rgba(67, 233, 123, 0.12);
  731 +}
521 732  
522   - .att-clock {
523   - font-size: 88rpx;
524   - font-weight: 300;
525   - color: #ffffff;
526   - letter-spacing: 4rpx;
527   - font-variant-numeric: tabular-nums;
528   - line-height: 1.1;
529   - text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
530   - }
  733 +.att-clock {
  734 + font-size: 88rpx;
  735 + font-weight: 300;
  736 + color: #ffffff;
  737 + letter-spacing: 4rpx;
  738 + font-variant-numeric: tabular-nums;
  739 + line-height: 1.1;
  740 + text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
  741 +}
531 742  
532   - .att-meta {
533   - margin-top: 16rpx;
534   - display: flex;
535   - align-items: center;
536   - justify-content: center;
537   - gap: 16rpx;
538   - }
  743 +.att-meta {
  744 + margin-top: 16rpx;
  745 + display: flex;
  746 + align-items: center;
  747 + justify-content: center;
  748 + gap: 16rpx;
  749 +}
539 750  
540   - .att-date {
541   - font-size: 28rpx;
542   - color: rgba(255, 255, 255, 0.92);
543   - }
  751 +.att-date {
  752 + font-size: 28rpx;
  753 + color: rgba(255, 255, 255, 0.92);
  754 +}
544 755  
545   - .att-week {
546   - font-size: 28rpx;
547   - color: #e8f5e9;
548   - font-weight: 500;
549   - }
  756 +.att-week {
  757 + font-size: 28rpx;
  758 + color: #e8f5e9;
  759 + font-weight: 500;
  760 +}
550 761  
551   - .att-user {
552   - margin-top: 12rpx;
553   - font-size: 26rpx;
554   - color: rgba(255, 255, 255, 0.88);
555   - }
  762 +.att-user {
  763 + margin-top: 12rpx;
  764 + font-size: 26rpx;
  765 + color: rgba(255, 255, 255, 0.88);
  766 +}
556 767  
557   - .att-rule {
558   - margin-top: 20rpx;
559   - display: flex;
560   - align-items: center;
561   - justify-content: center;
562   - gap: 12rpx;
563   - font-size: 24rpx;
564   - }
  768 +.att-rule {
  769 + margin-top: 20rpx;
  770 + display: flex;
  771 + align-items: center;
  772 + justify-content: center;
  773 + gap: 12rpx;
  774 + font-size: 24rpx;
  775 +}
565 776  
566   - .att-rule-label {
567   - color: rgba(255, 255, 255, 0.75);
568   - }
  777 +.att-rule-label {
  778 + color: rgba(255, 255, 255, 0.75);
  779 +}
569 780  
570   - .att-rule-val {
571   - color: #ffffff;
572   - font-weight: 600;
573   - }
  781 +.att-rule-val {
  782 + color: #ffffff;
  783 + font-weight: 600;
  784 +}
574 785  
575   - .att-body {
576   - margin-top: -28rpx;
577   - padding: 0 32rpx 32rpx;
578   - position: relative;
579   - z-index: 2;
580   - }
  786 +.att-body {
  787 + margin-top: -28rpx;
  788 + padding: 0 32rpx 32rpx;
  789 + position: relative;
  790 + z-index: 2;
  791 +}
581 792  
582   - .att-card {
583   - background: #ffffff;
584   - border-radius: 20rpx;
585   - padding: 28rpx;
586   - margin-bottom: 24rpx;
587   - box-shadow: 0 4rpx 24rpx 0 rgba(67, 233, 123, 0.08);
588   - }
  793 +.att-card {
  794 + background: #ffffff;
  795 + border-radius: 20rpx;
  796 + padding: 28rpx;
  797 + margin-bottom: 24rpx;
  798 + box-shadow: 0 4rpx 24rpx 0 rgba(67, 233, 123, 0.08);
  799 +}
589 800  
590   - .att-seg-card {
591   - padding: 20rpx 24rpx;
592   - }
  801 +.att-seg-card {
  802 + padding: 20rpx 24rpx;
  803 +}
593 804  
594   - .att-seg {
595   - display: flex;
596   - background: linear-gradient(135deg, #e8f5e9 60%, #c8e6c9 100%);
597   - border-radius: 16rpx;
598   - padding: 8rpx;
599   - }
  805 +.att-seg {
  806 + display: flex;
  807 + background: linear-gradient(135deg, #e8f5e9 60%, #c8e6c9 100%);
  808 + border-radius: 16rpx;
  809 + padding: 8rpx;
  810 +}
600 811  
601   - .att-seg-item {
602   - flex: 1;
603   - text-align: center;
604   - padding: 20rpx 0;
605   - font-size: 28rpx;
606   - color: #6a9c6a;
607   - border-radius: 12rpx;
608   - }
  812 +.att-seg-item {
  813 + flex: 1;
  814 + text-align: center;
  815 + padding: 20rpx 0;
  816 + font-size: 28rpx;
  817 + color: #6a9c6a;
  818 + border-radius: 12rpx;
  819 +}
609 820  
610   - .att-seg-item.is-active {
611   - background: #ffffff;
612   - color: #43a047;
613   - font-weight: 600;
614   - box-shadow: 0 2rpx 12rpx rgba(67, 160, 71, 0.15);
615   - }
  821 +.att-seg-item.is-active {
  822 + background: #ffffff;
  823 + color: #43a047;
  824 + font-weight: 600;
  825 + box-shadow: 0 2rpx 12rpx rgba(67, 160, 71, 0.15);
  826 +}
616 827  
617   - .att-punch-card {
618   - text-align: center;
619   - padding: 40rpx 28rpx 36rpx;
620   - }
  828 +.att-punch-card {
  829 + text-align: center;
  830 + padding: 40rpx 28rpx 36rpx;
  831 +}
621 832  
622   - .att-big-btn {
623   - width: 300rpx;
624   - height: 300rpx;
625   - margin: 0 auto;
626   - border-radius: 50%;
627   - display: flex;
628   - flex-direction: column;
629   - align-items: center;
630   - justify-content: center;
631   - background: linear-gradient(145deg, #66bb6a 0%, #43a047 45%, #388e3c 100%);
632   - box-shadow: 0 12rpx 40rpx 0 rgba(67, 160, 71, 0.35);
633   - transition: transform 0.15s, opacity 0.15s;
634   - }
  833 +.att-big-btn {
  834 + width: 300rpx;
  835 + height: 300rpx;
  836 + margin: 0 auto;
  837 + border-radius: 50%;
  838 + display: flex;
  839 + flex-direction: column;
  840 + align-items: center;
  841 + justify-content: center;
  842 + background: linear-gradient(145deg, #66bb6a 0%, #43a047 45%, #388e3c 100%);
  843 + box-shadow: 0 12rpx 40rpx 0 rgba(67, 160, 71, 0.35);
  844 + transition: transform 0.15s, opacity 0.15s;
  845 +}
635 846  
636   - .att-big-btn:active:not(.is-disabled):not(.is-all-done) {
637   - transform: scale(0.97);
638   - }
  847 +.att-big-btn:active:not(.is-disabled):not(.is-all-done) {
  848 + transform: scale(0.97);
  849 +}
639 850  
640   - .att-big-btn.is-disabled:not(.is-all-done) {
641   - opacity: 0.5;
642   - box-shadow: none;
643   - }
  851 +.att-big-btn.is-disabled:not(.is-all-done) {
  852 + opacity: 0.5;
  853 + box-shadow: none;
  854 +}
644 855  
645   - .att-big-btn.is-all-done {
646   - background: linear-gradient(145deg, #81c784 0%, #66bb6a 100%);
647   - box-shadow: 0 8rpx 28rpx 0 rgba(67, 160, 71, 0.25);
648   - }
  856 +.att-big-btn.is-all-done {
  857 + background: linear-gradient(145deg, #81c784 0%, #66bb6a 100%);
  858 + box-shadow: 0 8rpx 28rpx 0 rgba(67, 160, 71, 0.25);
  859 +}
649 860  
650   - .att-big-title {
651   - font-size: 40rpx;
652   - font-weight: 700;
653   - color: #ffffff;
654   - letter-spacing: 2rpx;
655   - }
  861 +.att-big-title {
  862 + font-size: 40rpx;
  863 + font-weight: 700;
  864 + color: #ffffff;
  865 + letter-spacing: 2rpx;
  866 +}
656 867  
657   - .att-big-sub {
658   - margin-top: 16rpx;
659   - font-size: 24rpx;
660   - color: rgba(255, 255, 255, 0.9);
661   - }
  868 +.att-big-sub {
  869 + margin-top: 16rpx;
  870 + font-size: 24rpx;
  871 + color: rgba(255, 255, 255, 0.9);
  872 +}
662 873  
663   - .att-big-done-text {
664   - margin-top: 12rpx;
665   - font-size: 28rpx;
666   - font-weight: 600;
667   - color: #ffffff;
668   - }
  874 +.att-big-done-text {
  875 + margin-top: 12rpx;
  876 + font-size: 28rpx;
  877 + font-weight: 600;
  878 + color: #ffffff;
  879 +}
669 880  
670   - .att-next-hint {
671   - margin-top: 24rpx;
672   - font-size: 24rpx;
673   - color: #388e3c;
674   - font-weight: 500;
675   - }
  881 +.att-next-hint {
  882 + margin-top: 24rpx;
  883 + font-size: 24rpx;
  884 + color: #388e3c;
  885 + font-weight: 500;
  886 +}
676 887  
677   - .att-loc-card {
678   - display: flex;
679   - align-items: flex-start;
680   - gap: 16rpx;
681   - }
  888 +.att-loc-card {
  889 + display: flex;
  890 + align-items: flex-start;
  891 + gap: 16rpx;
  892 +}
682 893  
683   - .att-loc-main {
684   - flex: 1;
685   - min-width: 0;
686   - }
  894 +.att-loc-main {
  895 + flex: 1;
  896 + min-width: 0;
  897 +}
687 898  
688   - .att-loc-text {
689   - font-size: 26rpx;
690   - color: #333333;
691   - line-height: 1.5;
692   - word-break: break-all;
693   - }
  899 +.att-loc-text {
  900 + font-size: 26rpx;
  901 + color: #333333;
  902 + line-height: 1.5;
  903 + word-break: break-all;
  904 +}
694 905  
695   - .att-loc-tip {
696   - display: block;
697   - margin-top: 8rpx;
698   - font-size: 22rpx;
699   - color: #6a9c6a;
700   - }
  906 +.att-loc-tip {
  907 + display: block;
  908 + margin-top: 8rpx;
  909 + font-size: 22rpx;
  910 + color: #6a9c6a;
  911 +}
701 912  
702   - .att-loc-actions {
703   - display: flex;
704   - align-items: center;
705   - flex-wrap: wrap;
706   - gap: 16rpx;
707   - margin-top: 16rpx;
708   - }
  913 +.att-loc-actions {
  914 + display: flex;
  915 + align-items: center;
  916 + flex-wrap: wrap;
  917 + gap: 16rpx;
  918 + margin-top: 16rpx;
  919 +}
709 920  
710   - .att-loc-action {
711   - font-size: 22rpx;
712   - color: #7f8c8d;
713   - background: #f5f7fa;
714   - border-radius: 999rpx;
715   - padding: 10rpx 20rpx;
716   - line-height: 1;
717   - }
  921 +.att-loc-action {
  922 + font-size: 22rpx;
  923 + color: #7f8c8d;
  924 + background: #f5f7fa;
  925 + border-radius: 999rpx;
  926 + padding: 10rpx 20rpx;
  927 + line-height: 1;
  928 +}
718 929  
719   - .att-loc-action--primary {
720   - color: #43a047;
721   - background: rgba(67, 160, 71, 0.12);
722   - }
  930 +.att-loc-action--primary {
  931 + color: #43a047;
  932 + background: rgba(67, 160, 71, 0.12);
  933 +}
723 934  
724   - .att-card-head {
725   - display: flex;
726   - align-items: center;
727   - justify-content: space-between;
728   - margin-bottom: 24rpx;
729   - padding-bottom: 20rpx;
730   - border-bottom: 1rpx solid #e8f5e9;
731   - }
  935 +.att-card-head {
  936 + display: flex;
  937 + align-items: center;
  938 + justify-content: space-between;
  939 + margin-bottom: 24rpx;
  940 + padding-bottom: 20rpx;
  941 + border-bottom: 1rpx solid #e8f5e9;
  942 +}
732 943  
733   - .att-card-title {
734   - font-size: 30rpx;
735   - font-weight: bold;
736   - color: #388e3c;
737   - letter-spacing: 1rpx;
738   - }
  944 +.att-card-title {
  945 + font-size: 30rpx;
  946 + font-weight: bold;
  947 + color: #388e3c;
  948 + letter-spacing: 1rpx;
  949 +}
739 950  
740   - .att-status-tag {
741   - font-size: 22rpx;
742   - color: #43a047;
743   - background: rgba(67, 160, 71, 0.12);
744   - padding: 8rpx 20rpx;
745   - border-radius: 8rpx;
746   - }
  951 +.att-status-tag {
  952 + font-size: 22rpx;
  953 + color: #43a047;
  954 + background: rgba(67, 160, 71, 0.12);
  955 + padding: 8rpx 20rpx;
  956 + border-radius: 8rpx;
  957 +}
747 958  
748   - .att-timeline {
749   - position: relative;
750   - padding-left: 8rpx;
751   - }
  959 +.att-timeline {
  960 + position: relative;
  961 + padding-left: 8rpx;
  962 +}
752 963  
753   - .att-tl-item {
754   - display: flex;
755   - flex-direction: row;
756   - align-items: flex-start;
757   - position: relative;
758   - min-height: 100rpx;
759   - overflow: visible;
760   - }
  964 +.att-tl-item {
  965 + display: flex;
  966 + flex-direction: row;
  967 + align-items: flex-start;
  968 + position: relative;
  969 + min-height: 100rpx;
  970 + overflow: visible;
  971 +}
761 972  
762   - .att-tl-item--last {
763   - min-height: auto;
764   - }
  973 +.att-tl-item--last {
  974 + min-height: auto;
  975 +}
765 976  
766   - .att-tl-dot {
767   - width: 20rpx;
768   - height: 20rpx;
769   - border-radius: 50%;
770   - background: #e0e0e0;
771   - margin-top: 8rpx;
772   - flex-shrink: 0;
773   - z-index: 1;
774   - }
  977 +.att-tl-dot {
  978 + width: 20rpx;
  979 + height: 20rpx;
  980 + border-radius: 50%;
  981 + background: #e0e0e0;
  982 + margin-top: 8rpx;
  983 + flex-shrink: 0;
  984 + z-index: 1;
  985 +}
775 986  
776   - .att-tl-dot.is-on {
777   - background: #43a047;
778   - box-shadow: 0 0 0 6rpx rgba(67, 160, 71, 0.2);
779   - }
  987 +.att-tl-dot.is-on {
  988 + background: #43a047;
  989 + box-shadow: 0 0 0 6rpx rgba(67, 160, 71, 0.2);
  990 +}
780 991  
781   - .att-tl-line {
782   - position: absolute;
783   - left: 9rpx;
784   - top: 28rpx;
785   - width: 2rpx;
786   - height: 72rpx;
787   - background: #e8f5e9;
788   - }
  992 +.att-tl-line {
  993 + position: absolute;
  994 + left: 9rpx;
  995 + top: 28rpx;
  996 + width: 2rpx;
  997 + height: 72rpx;
  998 + background: #e8f5e9;
  999 +}
789 1000  
790   - .att-tl-body {
791   - flex: 1;
792   - margin-left: 24rpx;
793   - padding-bottom: 28rpx;
794   - }
  1001 +.att-tl-body {
  1002 + flex: 1;
  1003 + margin-left: 24rpx;
  1004 + padding-bottom: 28rpx;
  1005 +}
795 1006  
796   - .att-tl-item--last .att-tl-body {
797   - padding-bottom: 0;
798   - }
  1007 +.att-tl-item--last .att-tl-body {
  1008 + padding-bottom: 0;
  1009 +}
799 1010  
800   - .att-tl-row {
801   - display: flex;
802   - justify-content: space-between;
803   - align-items: center;
804   - gap: 16rpx;
805   - }
  1011 +.att-tl-row {
  1012 + display: flex;
  1013 + justify-content: space-between;
  1014 + align-items: center;
  1015 + gap: 16rpx;
  1016 +}
806 1017  
807   - .att-tl-name {
808   - font-size: 28rpx;
809   - color: #6a9c6a;
810   - }
  1018 +.att-tl-name {
  1019 + font-size: 28rpx;
  1020 + color: #6a9c6a;
  1021 +}
811 1022  
812   - .att-tl-time {
813   - font-size: 28rpx;
814   - color: #333333;
815   - font-weight: 500;
816   - text-align: right;
817   - word-break: break-all;
818   - }
  1023 +.att-tl-time {
  1024 + font-size: 28rpx;
  1025 + color: #333333;
  1026 + font-weight: 500;
  1027 + text-align: right;
  1028 + word-break: break-all;
  1029 +}
819 1030  
820   - .att-empty {
821   - font-size: 26rpx;
822   - color: #909399;
823   - line-height: 1.6;
824   - text-align: center;
825   - padding: 16rpx 0;
826   - }
  1031 +.att-empty {
  1032 + font-size: 26rpx;
  1033 + color: #909399;
  1034 + line-height: 1.6;
  1035 + text-align: center;
  1036 + padding: 16rpx 0;
  1037 +}
827 1038  
828   - .att-more-title {
829   - font-size: 26rpx;
830   - color: #6a9c6a;
831   - margin-bottom: 20rpx;
832   - font-weight: 500;
833   - }
  1039 +.att-more-title {
  1040 + font-size: 26rpx;
  1041 + color: #6a9c6a;
  1042 + margin-bottom: 20rpx;
  1043 + font-weight: 500;
  1044 +}
834 1045  
835   - .att-more-title--second {
836   - margin-top: 28rpx;
837   - margin-bottom: 16rpx;
838   - }
  1046 +.att-more-title--second {
  1047 + margin-top: 28rpx;
  1048 + margin-bottom: 16rpx;
  1049 +}
839 1050  
840   - .att-upload-wrap {
841   - margin-top: 20rpx;
842   - }
  1051 +.att-upload-wrap {
  1052 + margin-top: 20rpx;
  1053 +}
843 1054  
844   - .att-safe {
845   - height: 24rpx;
846   - }
  1055 +.att-safe {
  1056 + height: 24rpx;
  1057 +}
847 1058  
848   - .att-loading-mask {
849   - position: fixed;
850   - left: 0;
851   - right: 0;
852   - top: 0;
853   - bottom: 0;
854   - display: flex;
855   - flex-direction: column;
856   - align-items: center;
857   - justify-content: center;
858   - background: rgba(255, 255, 255, 0.75);
859   - z-index: 999;
860   - }
  1059 +.att-loading-mask {
  1060 + position: fixed;
  1061 + left: 0;
  1062 + right: 0;
  1063 + top: 0;
  1064 + bottom: 0;
  1065 + display: flex;
  1066 + flex-direction: column;
  1067 + align-items: center;
  1068 + justify-content: center;
  1069 + background: rgba(255, 255, 255, 0.75);
  1070 + z-index: 999;
  1071 +}
861 1072  
862   - .att-loading-txt {
863   - margin-top: 16rpx;
864   - font-size: 26rpx;
865   - color: #6a9c6a;
866   - }
  1073 +.att-loading-txt {
  1074 + margin-top: 16rpx;
  1075 + font-size: 26rpx;
  1076 + color: #6a9c6a;
  1077 +}
867 1078 </style>
... ...
绿纤uni-app/pagesA/my-application-list/my-application-list.vue
... ... @@ -5,6 +5,18 @@
5 5 <view class="header-sub">查看我发起的流程申请,支持进入详情查看表单与审批进度</view>
6 6 </view>
7 7  
  8 + <view class="filter-bar">
  9 + <view
  10 + v-for="tab in statusTabs"
  11 + :key="tab.key"
  12 + class="filter-item"
  13 + :class="statusFilter === tab.key ? 'filter-item--active' : ''"
  14 + @tap="changeStatusFilter(tab.key)"
  15 + >
  16 + {{ tab.label }}
  17 + </view>
  18 + </view>
  19 +
8 20 <view v-if="loading && !list.length" class="state-card">
9 21 <text class="state-text">加载中...</text>
10 22 </view>
... ... @@ -63,6 +75,13 @@ export default {
63 75 return {
64 76 loading: false,
65 77 list: [],
  78 + statusFilter: 'all',
  79 + statusTabs: [
  80 + { key: 'all', label: '全部' },
  81 + { key: 'running', label: '审核中' },
  82 + { key: 'passed', label: '已通过' },
  83 + { key: 'rejected', label: '不通过' }
  84 + ],
66 85 query: {
67 86 currentPage: 1,
68 87 pageSize: 20,
... ... @@ -73,8 +92,14 @@ export default {
73 92 }
74 93 },
75 94 computed: {
  95 + filteredList() {
  96 + if (!Array.isArray(this.list)) return []
  97 + const st = this.getStatusFilterNumber(this.statusFilter)
  98 + if (st === null) return this.list
  99 + return this.list.filter(item => Number(item.status) === st)
  100 + },
76 101 displayList() {
77   - return (this.list || []).map(item => ({
  102 + return (this.filteredList || []).map(item => ({
78 103 ...item,
79 104 statusClassName: this.statusClass(item.status),
80 105 statusText: this.getStatusText(item.status),
... ... @@ -115,6 +140,11 @@ export default {
115 140 uni.stopPullDownRefresh()
116 141 }
117 142 },
  143 + changeStatusFilter(key) {
  144 + if (this.statusFilter === key) return
  145 + this.statusFilter = key
  146 + this.refresh()
  147 + },
118 148 refresh() {
119 149 this.query.currentPage = 1
120 150 this.total = 0
... ... @@ -151,6 +181,15 @@ export default {
151 181 }
152 182 return map[Number(status)] || 'status-default'
153 183 },
  184 + // 业务状态:与 getStatusText / 后端 FlowTaskEntity.status 对齐
  185 + // all: 全部;running: 待审核;passed: 已通过;rejected: 已驳回(不通过)
  186 + getStatusFilterNumber(key) {
  187 + if (key === 'all') return null
  188 + if (key === 'running') return 1
  189 + if (key === 'passed') return 2
  190 + if (key === 'rejected') return 3
  191 + return null
  192 + },
154 193 getUrgentText(val) {
155 194 const map = {
156 195 1: '普通',
... ... @@ -192,6 +231,28 @@ export default {
192 231 box-sizing: border-box;
193 232 background: linear-gradient(180deg, #e8f5e9 0%, #f7fff8 100%);
194 233 }
  234 +.filter-bar {
  235 + display: flex;
  236 + gap: 16rpx;
  237 + padding: 0 12rpx;
  238 + margin-bottom: 22rpx;
  239 +}
  240 +.filter-item {
  241 + flex: 1;
  242 + text-align: center;
  243 + padding: 14rpx 0;
  244 + border-radius: 999rpx;
  245 + background: rgba(255, 255, 255, 0.7);
  246 + color: #7a8b7b;
  247 + font-size: 24rpx;
  248 + font-weight: 600;
  249 + border: 1px solid rgba(46, 125, 50, 0.12);
  250 +}
  251 +.filter-item--active {
  252 + background: #e8f5e9;
  253 + color: #2e7d32;
  254 + border-color: rgba(46, 125, 50, 0.35);
  255 +}
195 256 .header-card,
196 257 .state-card,
197 258 .apply-card {
... ...
绿纤uni-app/unpackage/dist/dev/mp-weixin/pages/attendance-punch/attendance-punch.js
... ... @@ -134,6 +134,15 @@ var render = function () {
134 134 var _vm = this
135 135 var _h = _vm.$createElement
136 136 var _c = _vm._self._c || _h
  137 + var m0 = Number(_vm.punchType)
  138 + _vm.$mp.data = Object.assign(
  139 + {},
  140 + {
  141 + $root: {
  142 + m0: m0,
  143 + },
  144 + }
  145 + )
137 146 }
138 147 var recyclableRender = false
139 148 var staticRenderFns = []
... ... @@ -312,13 +321,6 @@ var _attendanceFence = __webpack_require__(/*! @/service/attendance-fence.js */
312 321 //
313 322 //
314 323 //
315   -//
316   -//
317   -//
318   -//
319   -//
320   -//
321   -//
322 324 var _default = {
323 325 data: function data() {
324 326 return {
... ... @@ -341,7 +343,11 @@ var _default = {
341 343 photoUrl: '',
342 344 photoFileList: [],
343 345 clockTime: '--:--:--',
344   - clockTimer: null
  346 + clockTimer: null,
  347 + wifiSSID: '',
  348 + wifiBSSID: '',
  349 + wifiLoading: false,
  350 + wifiError: ''
345 351 };
346 352 },
347 353 computed: {
... ... @@ -385,14 +391,95 @@ var _default = {
385 391 }
386 392 return '点击获取定位,用于考勤校验';
387 393 },
  394 + requireFenceCheck: function requireFenceCheck() {
  395 + var sf = this.storeFence;
  396 + if (!sf) return false;
  397 + var rf = sf.requireFenceCheck != null ? sf.requireFenceCheck : sf.RequireFenceCheck;
  398 + return rf === true || rf === 1 || rf === '1';
  399 + },
  400 + requireWifiCheck: function requireWifiCheck() {
  401 + var sf = this.storeFence;
  402 + if (!sf) return false;
  403 + var rw = sf.requireWifiCheck != null ? sf.requireWifiCheck : sf.RequireWifiCheck;
  404 + return rw === true || rw === 1 || rw === '1';
  405 + },
  406 + /** 门店是否已配置 Wi-Fi 成对白名单(与接口 attendanceWifiPairs 一致) */wifiPairRulesCount: function wifiPairRulesCount() {
  407 + var sf = this.storeFence;
  408 + if (!sf) return 0;
  409 + var raw = sf.attendanceWifiPairs != null ? sf.attendanceWifiPairs : sf.AttendanceWifiPairs;
  410 + return Array.isArray(raw) ? raw.length : 0;
  411 + },
  412 + wifiVerifyPairStrict: function wifiVerifyPairStrict() {
  413 + var sf = this.storeFence;
  414 + if (!sf) return false;
  415 + var v = sf.attendanceWifiVerifyPair != null ? sf.attendanceWifiVerifyPair : sf.AttendanceWifiVerifyPair;
  416 + return Number(v) === 1;
  417 + },
  418 + wifiMatchesStore: function wifiMatchesStore() {
  419 + var _this = this;
  420 + var sf = this.storeFence;
  421 + var rawPairs = sf && (sf.attendanceWifiPairs != null ? sf.attendanceWifiPairs : sf.AttendanceWifiPairs);
  422 + var pairs = Array.isArray(rawPairs) ? rawPairs : [];
  423 + var curSsid = (this.wifiSSID || '').trim();
  424 + var curB = this.normalizeAttendanceBssid(this.wifiBSSID);
  425 + var hasClientBssid = !!curB;
  426 + if (pairs.length > 0) {
  427 + var strict = this.wifiVerifyPairStrict;
  428 + if (strict) {
  429 + if (hasClientBssid) {
  430 + // 与后端一致:有 BSSID 时以路由器 MAC 为准(避免 iOS/微信 SSID 为空或不准确)
  431 + return pairs.some(function (p) {
  432 + var pb = _this.normalizeAttendanceBssid(p.bssid);
  433 + return !!pb && pb === curB;
  434 + });
  435 + }
  436 + if (this.isInFence !== true) return false;
  437 + if (!curSsid) return false;
  438 + return pairs.some(function (p) {
  439 + var ps = String(p.ssid || '').trim();
  440 + return !!ps && ps.toLowerCase() === curSsid.toLowerCase();
  441 + });
  442 + }
  443 + return pairs.some(function (p) {
  444 + var ps = String(p.ssid || '').trim();
  445 + var pb = _this.normalizeAttendanceBssid(p.bssid);
  446 + var ssidHit = !!ps && !!curSsid && ps.toLowerCase() === curSsid.toLowerCase();
  447 + var bssidHit = !!pb && pb === curB;
  448 + return ssidHit || bssidHit;
  449 + });
  450 + }
  451 + return false;
  452 + },
  453 + /** 正常上班时是否满足门店「围栏 / Wi-Fi / 或二者择一」策略(与后端一致) */normalPunchLocationOk: function normalPunchLocationOk() {
  454 + if (Number(this.punchType) !== 1) return true;
  455 + var rf = this.requireFenceCheck;
  456 + var rw = this.requireWifiCheck;
  457 + if (!rf && !rw) return true;
  458 + var fenceOk = !rf || this.isInFence === true;
  459 + var wifiOk = !rw || this.wifiMatchesStore;
  460 + if (rf && rw) return fenceOk || wifiOk;
  461 + if (rf) return fenceOk;
  462 + return wifiOk;
  463 + },
388 464 fenceStatusText: function fenceStatusText() {
389   - if (this.locating) return '正在校验打卡范围…';
390   - if (Number(this.punchType) === 2) return '外勤打卡不受门店围栏限制';
391   - if (!this.fenceEnabled) return '当前门店未配置围栏,正常打卡不受范围限制';
392   - if (this.longitude == null || this.latitude == null) return '点击更新定位后校验是否在门店范围内';
393   - if (this.isInFence === true) return '当前位于门店打卡范围内,可正常上下班打卡';
394   - if (this.isInFence === false) return '当前不在门店打卡范围内,正常打卡不可用';
395   - return '定位完成后自动校验门店围栏范围';
  465 + if (this.locating) return '正在校验位置…';
  466 + if (Number(this.punchType) === 2) return '外勤打卡不受门店位置与 Wi-Fi 限制';
  467 + var rf = this.requireFenceCheck;
  468 + var rw = this.requireWifiCheck;
  469 + if (!rf && !rw) return '当前门店未要求范围或 Wi-Fi,正常打卡可直接进行';
  470 + var fenceLine = rf ? this.longitude == null || this.latitude == null ? '需定位后判断是否在打卡范围内' : this.isInFence === true ? '已在打卡范围内' : '不在打卡范围内' : '';
  471 + var hasPairs = this.wifiPairRulesCount > 0;
  472 + var wifiLine = rw ? !hasPairs ? '门店已要求 Wi-Fi 打卡,但未配置网络白名单,请联系管理员在门店资料中维护' : this.wifiMatchesStore ? '已匹配门店 Wi-Fi 规则' : '未匹配门店 Wi-Fi 规则(开启「对应」且无 BSSID 时需同时在围栏内且 SSID 正确)' : '';
  473 + if (rf && rw) {
  474 + return "\u95E8\u5E97\u8981\u6C42\uFF1A\u8303\u56F4\u6216 Wi-Fi \u6EE1\u8DB3\u5176\u4E00\u5373\u53EF\u3002".concat(fenceLine, "\uFF1B").concat(wifiLine);
  475 + }
  476 + if (rf) {
  477 + if (this.longitude == null || this.latitude == null) return '点击更新定位后校验是否在门店范围内';
  478 + if (this.isInFence === true) return '当前位于门店打卡范围内,可正常上下班打卡';
  479 + if (this.isInFence === false) return '当前不在门店打卡范围内,正常打卡不可用';
  480 + return '定位完成后自动校验门店围栏范围';
  481 + }
  482 + return wifiLine || '请连接门店指定 Wi-Fi 后再试';
396 483 },
397 484 punchInDone: function punchInDone() {
398 485 return !!(this.detail && this.detail.punchIn && this.detail.punchIn.time);
... ... @@ -425,11 +512,38 @@ var _default = {
425 512 if (this.loading || this.submitting || this.locating) return false;
426 513 if (!this.userInfo.userId) return false;
427 514 if (this.nextPunchDirection == null) return false;
428   - if (Number(this.punchType) === 1 && this.fenceEnabled && this.isInFence !== true) return false;
  515 + if (Number(this.punchType) === 1 && !this.normalPunchLocationOk) return false;
429 516 return true;
430 517 },
431 518 canViewFenceMap: function canViewFenceMap() {
432 519 return !!(this.storeFence && this.storeFence.hasFence && this.storeFence.fencePolygons || this.storeFence && this.storeFence.longitude != null && this.storeFence.latitude != null || this.longitude != null && this.latitude != null);
  520 + },
  521 + wifiMainText: function wifiMainText() {
  522 + if (this.wifiLoading) return '正在获取 Wi-Fi 信息…';
  523 + if (this.wifiError) return this.wifiError;
  524 + if (this.wifiSSID) return "\u5F53\u524D\u7F51\u7EDC\uFF1A".concat(this.wifiSSID);
  525 + return '未获取到 Wi-Fi 名称';
  526 + },
  527 + wifiTipText: function wifiTipText() {
  528 + if (this.wifiLoading) return '用于核对是否在门店网络环境';
  529 + if (this.wifiError) {
  530 + if (this.wifiError.indexOf('模拟器') !== -1 || this.wifiError.indexOf('真机') !== -1) {
  531 + return 'Wi-Fi 由系统提供,开发者工具无此能力';
  532 + }
  533 + return '可点击「刷新 Wi-Fi」重试;部分机型需开启定位权限';
  534 + }
  535 + if (Number(this.punchType) === 1 && this.requireWifiCheck) {
  536 + if (this.wifiPairRulesCount === 0) {
  537 + return '门店已开启 Wi-Fi 校验,白名单未配置,请联系管理员';
  538 + }
  539 + var strict = this.wifiVerifyPairStrict;
  540 + if (this.wifiMatchesStore) {
  541 + return strict ? '已满足门店 Wi-Fi(含对应校验)' : '已命中门店 Wi-Fi 白名单';
  542 + }
  543 + return strict ? '未满足:若已开「对应」校验,请连门店 AP 或进入围栏且 SSID 正确' : '未命中白名单(可核对 SSID/BSSID;与围栏组合时满足其一即可)';
  544 + }
  545 + if (this.wifiBSSID) return "BSSID\uFF1A".concat(this.wifiBSSID);
  546 + return '名称由系统返回;iOS 常可稳定获取 BSSID';
433 547 }
434 548 },
435 549 onLoad: function onLoad() {
... ... @@ -453,28 +567,39 @@ var _default = {
453 567 });
454 568 },
455 569 methods: {
  570 + /** 与后端 NormalizeAttendanceBssid 一致:连字符统一为冒号、小写 */normalizeAttendanceBssid: function normalizeAttendanceBssid(s) {
  571 + if (!s) return '';
  572 + var t = String(s).trim().replace(/-/g, ':').replace(/\s+/g, '').toLowerCase();
  573 + if (t.indexOf(':') < 0 && t.length === 12 && /^[0-9a-f]{12}$/.test(t)) {
  574 + t = t.match(/.{2}/g).join(':');
  575 + }
  576 + return t;
  577 + },
456 578 /** 切回「正常」时只清空外勤说明;照片正常/外勤共用,保留 */setPunchType: function setPunchType(type) {
457 579 this.punchType = type;
458   - if (type === 1) {
  580 + if (Number(type) === 1) {
459 581 this.remark = '';
  582 + this.refreshWifiInfo();
460 583 }
461 584 },
462 585 initializePage: function initializePage() {
463   - var _this = this;
  586 + var _this2 = this;
464 587 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee() {
  588 + var wifiTask;
465 589 return _regenerator.default.wrap(function _callee$(_context) {
466 590 while (1) {
467 591 switch (_context.prev = _context.next) {
468 592 case 0:
469 593 _context.next = 2;
470   - return _this.loadPunchConfig();
  594 + return _this2.loadPunchConfig();
471 595 case 2:
472   - _context.next = 4;
473   - return _this.refreshLocation();
474   - case 4:
475   - _context.next = 6;
476   - return _this.loadDetail();
477   - case 6:
  596 + wifiTask = Number(_this2.punchType) === 1 ? _this2.refreshWifiInfo() : Promise.resolve();
  597 + _context.next = 5;
  598 + return Promise.all([_this2.refreshLocation(), wifiTask]);
  599 + case 5:
  600 + _context.next = 7;
  601 + return _this2.loadDetail();
  602 + case 7:
478 603 case "end":
479 604 return _context.stop();
480 605 }
... ... @@ -484,9 +609,9 @@ var _default = {
484 609 },
485 610 onMainPunch: function onMainPunch() {
486 611 if (!this.nextPunchDirection) return;
487   - if (Number(this.punchType) === 1 && this.fenceEnabled && this.isInFence !== true) {
  612 + if (Number(this.punchType) === 1 && !this.normalPunchLocationOk) {
488 613 uni.showToast({
489   - title: '当前不在门店打卡范围内,无法正常打卡',
  614 + title: '未满足门店要求(需在范围内或指定 Wi-Fi/BSSID),可改用外勤',
490 615 icon: 'none'
491 616 });
492 617 return;
... ... @@ -518,42 +643,42 @@ var _default = {
518 643 return "".concat(y, "-").concat(m, "-").concat(day);
519 644 },
520 645 loadDetail: function loadDetail() {
521   - var _this2 = this;
  646 + var _this3 = this;
522 647 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee2() {
523 648 var res;
524 649 return _regenerator.default.wrap(function _callee2$(_context2) {
525 650 while (1) {
526 651 switch (_context2.prev = _context2.next) {
527 652 case 0:
528   - if (_this2.userInfo.userId) {
  653 + if (_this3.userInfo.userId) {
529 654 _context2.next = 2;
530 655 break;
531 656 }
532 657 return _context2.abrupt("return");
533 658 case 2:
534   - _this2.loading = true;
  659 + _this3.loading = true;
535 660 _context2.prev = 3;
536 661 _context2.next = 6;
537   - return _this2.API.getAttendanceDetail({
538   - userId: _this2.userInfo.userId,
539   - attendanceDate: _this2.today
  662 + return _this3.API.getAttendanceDetail({
  663 + userId: _this3.userInfo.userId,
  664 + attendanceDate: _this3.today
540 665 });
541 666 case 6:
542 667 res = _context2.sent;
543 668 if (res && Number(res.code) === 200 && res.data) {
544   - _this2.detail = res.data;
  669 + _this3.detail = res.data;
545 670 } else {
546   - _this2.detail = null;
  671 + _this3.detail = null;
547 672 }
548 673 _context2.next = 13;
549 674 break;
550 675 case 10:
551 676 _context2.prev = 10;
552 677 _context2.t0 = _context2["catch"](3);
553   - _this2.detail = null;
  678 + _this3.detail = null;
554 679 case 13:
555 680 _context2.prev = 13;
556   - _this2.loading = false;
  681 + _this3.loading = false;
557 682 return _context2.finish(13);
558 683 case 16:
559 684 case "end":
... ... @@ -564,52 +689,52 @@ var _default = {
564 689 }))();
565 690 },
566 691 loadPunchConfig: function loadPunchConfig() {
567   - var _this3 = this;
  692 + var _this4 = this;
568 693 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee3() {
569 694 var res;
570 695 return _regenerator.default.wrap(function _callee3$(_context3) {
571 696 while (1) {
572 697 switch (_context3.prev = _context3.next) {
573 698 case 0:
574   - if (_this3.userInfo.userId) {
  699 + if (_this4.userInfo.userId) {
575 700 _context3.next = 7;
576 701 break;
577 702 }
578   - _this3.punchConfig = null;
579   - _this3.storeFence = null;
580   - _this3.attendanceGroup = null;
581   - _this3.fenceEnabled = false;
582   - _this3.isInFence = null;
  703 + _this4.punchConfig = null;
  704 + _this4.storeFence = null;
  705 + _this4.attendanceGroup = null;
  706 + _this4.fenceEnabled = false;
  707 + _this4.isInFence = null;
583 708 return _context3.abrupt("return");
584 709 case 7:
585 710 _context3.prev = 7;
586 711 _context3.next = 10;
587   - return _this3.API.getCurrentAttendancePunchConfig({});
  712 + return _this4.API.getCurrentAttendancePunchConfig({});
588 713 case 10:
589 714 res = _context3.sent;
590 715 if (res && Number(res.code) === 200 && res.data) {
591   - _this3.punchConfig = res.data;
592   - _this3.storeFence = res.data.storeFence || null;
593   - _this3.attendanceGroup = res.data.attendanceGroup || null;
594   - _this3.fenceEnabled = !!(_this3.storeFence && _this3.storeFence.hasFence && _this3.storeFence.fencePolygons);
595   - _this3.evaluateFenceRange();
  716 + _this4.punchConfig = res.data;
  717 + _this4.storeFence = res.data.storeFence || null;
  718 + _this4.attendanceGroup = res.data.attendanceGroup || null;
  719 + _this4.fenceEnabled = !!(_this4.storeFence && _this4.storeFence.hasFence && _this4.storeFence.fencePolygons);
  720 + _this4.evaluateFenceRange();
596 721 } else {
597   - _this3.punchConfig = null;
598   - _this3.storeFence = null;
599   - _this3.attendanceGroup = null;
600   - _this3.fenceEnabled = false;
601   - _this3.isInFence = null;
  722 + _this4.punchConfig = null;
  723 + _this4.storeFence = null;
  724 + _this4.attendanceGroup = null;
  725 + _this4.fenceEnabled = false;
  726 + _this4.isInFence = null;
602 727 }
603 728 _context3.next = 21;
604 729 break;
605 730 case 14:
606 731 _context3.prev = 14;
607 732 _context3.t0 = _context3["catch"](7);
608   - _this3.punchConfig = null;
609   - _this3.storeFence = null;
610   - _this3.attendanceGroup = null;
611   - _this3.fenceEnabled = false;
612   - _this3.isInFence = null;
  733 + _this4.punchConfig = null;
  734 + _this4.storeFence = null;
  735 + _this4.attendanceGroup = null;
  736 + _this4.fenceEnabled = false;
  737 + _this4.isInFence = null;
613 738 case 21:
614 739 case "end":
615 740 return _context3.stop();
... ... @@ -618,24 +743,84 @@ var _default = {
618 743 }, _callee3, null, [[7, 14]]);
619 744 }))();
620 745 },
  746 + refreshWifiInfo: function refreshWifiInfo() {
  747 + var _this5 = this;
  748 + return new Promise(function (resolve) {
  749 + try {
  750 + var sys = uni.getSystemInfoSync();
  751 + if (sys && sys.platform === 'devtools') {
  752 + _this5.wifiLoading = false;
  753 + _this5.wifiSSID = '';
  754 + _this5.wifiBSSID = '';
  755 + _this5.wifiError = '开发者工具模拟器不支持 Wi-Fi,请用真机预览或扫码调试';
  756 + resolve();
  757 + return;
  758 + }
  759 + } catch (e) {}
  760 + _this5.wifiLoading = true;
  761 + _this5.wifiError = '';
  762 + uni.startWifi({
  763 + success: function success() {
  764 + uni.getConnectedWifi({
  765 + success: function success(res) {
  766 + var w = res && res.wifi;
  767 + _this5.wifiSSID = w && w.SSID ? String(w.SSID) : '';
  768 + _this5.wifiBSSID = w && w.BSSID ? String(w.BSSID) : '';
  769 + _this5.wifiLoading = false;
  770 + if (!_this5.wifiSSID && !_this5.wifiBSSID) {
  771 + _this5.wifiError = '未连接 Wi-Fi';
  772 + }
  773 + resolve();
  774 + },
  775 + fail: function fail(err) {
  776 + _this5.wifiLoading = false;
  777 + var msg = err && err.errMsg ? String(err.errMsg) : '';
  778 + if (msg.indexOf('auth deny') !== -1 || msg.indexOf('authorize') !== -1) {
  779 + _this5.wifiError = '需授权获取 Wi-Fi 信息';
  780 + } else if (msg.indexOf('not connected') !== -1 || err && Number(err.errCode) === 12002) {
  781 + _this5.wifiError = '当前未连接 Wi-Fi';
  782 + } else {
  783 + _this5.wifiError = '无法获取 Wi-Fi 信息';
  784 + }
  785 + _this5.wifiSSID = '';
  786 + _this5.wifiBSSID = '';
  787 + resolve();
  788 + }
  789 + });
  790 + },
  791 + fail: function fail(err) {
  792 + _this5.wifiLoading = false;
  793 + _this5.wifiSSID = '';
  794 + _this5.wifiBSSID = '';
  795 + var msg = err && err.errMsg ? String(err.errMsg) : '';
  796 + if (msg.indexOf('devtools') !== -1 || msg.indexOf('not support') !== -1) {
  797 + _this5.wifiError = '当前环境不支持 Wi-Fi,请用真机预览';
  798 + } else {
  799 + _this5.wifiError = '无法启动 Wi-Fi 模块,请用真机重试';
  800 + }
  801 + resolve();
  802 + }
  803 + });
  804 + });
  805 + },
621 806 refreshLocation: function refreshLocation() {
622   - var _this4 = this;
  807 + var _this6 = this;
623 808 this.locating = true;
624 809 return new Promise(function (resolve) {
625 810 uni.getLocation({
626 811 type: 'gcj02',
627 812 isHighAccuracy: true,
628 813 success: function success(res) {
629   - _this4.longitude = res.longitude;
630   - _this4.latitude = res.latitude;
631   - _this4.address = '';
632   - _this4.evaluateFenceRange();
633   - _this4.locating = false;
  814 + _this6.longitude = res.longitude;
  815 + _this6.latitude = res.latitude;
  816 + _this6.address = '';
  817 + _this6.evaluateFenceRange();
  818 + _this6.locating = false;
634 819 resolve();
635 820 },
636 821 fail: function fail() {
637   - _this4.locating = false;
638   - _this4.isInFence = null;
  822 + _this6.locating = false;
  823 + _this6.isInFence = null;
639 824 uni.showToast({
640 825 title: '定位失败,请检查系统定位权限',
641 826 icon: 'none'
... ... @@ -663,7 +848,7 @@ var _default = {
663 848 });
664 849 },
665 850 evaluateFenceRange: function evaluateFenceRange() {
666   - var _this5 = this;
  851 + var _this7 = this;
667 852 if (!this.fenceEnabled) {
668 853 this.isInFence = true;
669 854 return;
... ... @@ -678,14 +863,14 @@ var _default = {
678 863 return;
679 864 }
680 865 this.isInFence = polygons.some(function (polygon) {
681   - return _this5.isPointInPolygon(_this5.longitude, _this5.latitude, polygon);
  866 + return _this7.isPointInPolygon(_this7.longitude, _this7.latitude, polygon);
682 867 });
683 868 },
684 869 isPointInPolygon: function isPointInPolygon(lng, lat, polygon) {
685 870 return (0, _attendanceFence.isPointInPolygon)(lng, lat, polygon);
686 871 },
687 872 afterReadPhoto: function afterReadPhoto(event) {
688   - var _this6 = this;
  873 + var _this8 = this;
689 874 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee4() {
690 875 var lists, file, fileObj, result;
691 876 return _regenerator.default.wrap(function _callee4$(_context4) {
... ... @@ -705,13 +890,13 @@ var _default = {
705 890 title: '上传中'
706 891 });
707 892 _context4.next = 8;
708   - return _this6.API.uploadFile(fileObj);
  893 + return _this8.API.uploadFile(fileObj);
709 894 case 8:
710 895 result = _context4.sent;
711 896 uni.hideLoading();
712 897 if (result && result.code === 200 && result.data && result.data.url) {
713   - _this6.photoUrl = result.data.url;
714   - _this6.photoFileList = [{
  898 + _this8.photoUrl = result.data.url;
  899 + _this8.photoFileList = [{
715 900 url: result.data.url,
716 901 status: 'success'
717 902 }];
... ... @@ -744,14 +929,14 @@ var _default = {
744 929 this.photoFileList = [];
745 930 },
746 931 doPunch: function doPunch(direction) {
747   - var _this7 = this;
  932 + var _this9 = this;
748 933 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee5() {
749   - var isFieldWork, payload, res;
  934 + var isNormal, needGps, isFieldWork, payload, res;
750 935 return _regenerator.default.wrap(function _callee5$(_context5) {
751 936 while (1) {
752 937 switch (_context5.prev = _context5.next) {
753 938 case 0:
754   - if (_this7.userInfo.userId) {
  939 + if (_this9.userInfo.userId) {
755 940 _context5.next = 3;
756 941 break;
757 942 }
... ... @@ -761,81 +946,99 @@ var _default = {
761 946 });
762 947 return _context5.abrupt("return");
763 948 case 3:
764   - if (!(_this7.longitude == null || _this7.latitude == null)) {
765   - _context5.next = 9;
  949 + isNormal = Number(_this9.punchType) === 1;
  950 + needGps = !isNormal || !!(_this9.storeFence && _this9.storeFence.requireFenceCheck);
  951 + if (!needGps) {
  952 + _context5.next = 14;
766 953 break;
767 954 }
768   - _context5.next = 6;
769   - return _this7.refreshLocation();
770   - case 6:
771   - if (!(_this7.longitude == null || _this7.latitude == null)) {
  955 + if (!(_this9.longitude == null || _this9.latitude == null)) {
772 956 _context5.next = 9;
773 957 break;
774 958 }
  959 + _context5.next = 9;
  960 + return _this9.refreshLocation();
  961 + case 9:
  962 + if (!(_this9.longitude == null || _this9.latitude == null)) {
  963 + _context5.next = 12;
  964 + break;
  965 + }
775 966 uni.showToast({
776 967 title: '请先完成定位',
777 968 icon: 'none'
778 969 });
779 970 return _context5.abrupt("return");
780   - case 9:
781   - isFieldWork = Number(_this7.punchType) === 2;
  971 + case 12:
  972 + _context5.next = 17;
  973 + break;
  974 + case 14:
  975 + if (!(isNormal && _this9.longitude == null)) {
  976 + _context5.next = 17;
  977 + break;
  978 + }
  979 + _context5.next = 17;
  980 + return _this9.refreshLocation();
  981 + case 17:
  982 + isFieldWork = Number(_this9.punchType) === 2;
782 983 payload = {
783   - userId: _this7.userInfo.userId,
  984 + userId: _this9.userInfo.userId,
784 985 punchDirection: direction,
785   - punchType: Number(_this7.punchType),
786   - longitude: _this7.longitude,
787   - latitude: _this7.latitude,
788   - address: _this7.address || _this7.locationText,
789   - photoUrl: _this7.photoUrl || undefined,
790   - remark: isFieldWork ? (_this7.remark || '').trim() || undefined : undefined
  986 + punchType: Number(_this9.punchType),
  987 + longitude: _this9.longitude != null ? _this9.longitude : undefined,
  988 + latitude: _this9.latitude != null ? _this9.latitude : undefined,
  989 + address: _this9.address || _this9.locationText,
  990 + wifiSsid: (_this9.wifiSSID || '').trim() || undefined,
  991 + wifiBssid: (_this9.wifiBSSID || '').trim() || undefined,
  992 + photoUrl: _this9.photoUrl || undefined,
  993 + remark: isFieldWork ? (_this9.remark || '').trim() || undefined : undefined
791 994 };
792   - _this7.submitting = true;
793   - _context5.prev = 12;
794   - _context5.next = 15;
795   - return _this7.API.submitAttendancePunch(payload);
796   - case 15:
  995 + _this9.submitting = true;
  996 + _context5.prev = 20;
  997 + _context5.next = 23;
  998 + return _this9.API.submitAttendancePunch(payload);
  999 + case 23:
797 1000 res = _context5.sent;
798 1001 if (!(res && Number(res.code) === 200)) {
799   - _context5.next = 25;
  1002 + _context5.next = 33;
800 1003 break;
801 1004 }
802 1005 uni.showToast({
803 1006 title: '打卡成功',
804 1007 icon: 'success'
805 1008 });
806   - _this7.remark = '';
807   - _this7.photoUrl = '';
808   - _this7.photoFileList = [];
809   - _context5.next = 23;
810   - return _this7.loadDetail();
811   - case 23:
812   - _context5.next = 26;
  1009 + _this9.remark = '';
  1010 + _this9.photoUrl = '';
  1011 + _this9.photoFileList = [];
  1012 + _context5.next = 31;
  1013 + return _this9.loadDetail();
  1014 + case 31:
  1015 + _context5.next = 34;
813 1016 break;
814   - case 25:
  1017 + case 33:
815 1018 uni.showToast({
816 1019 title: res && (res.msg || res.message) || '打卡失败',
817 1020 icon: 'none'
818 1021 });
819   - case 26:
820   - _context5.next = 31;
  1022 + case 34:
  1023 + _context5.next = 39;
821 1024 break;
822   - case 28:
823   - _context5.prev = 28;
824   - _context5.t0 = _context5["catch"](12);
  1025 + case 36:
  1026 + _context5.prev = 36;
  1027 + _context5.t0 = _context5["catch"](20);
825 1028 uni.showToast({
826 1029 title: '打卡失败',
827 1030 icon: 'none'
828 1031 });
829   - case 31:
830   - _context5.prev = 31;
831   - _this7.submitting = false;
832   - return _context5.finish(31);
833   - case 34:
  1032 + case 39:
  1033 + _context5.prev = 39;
  1034 + _this9.submitting = false;
  1035 + return _context5.finish(39);
  1036 + case 42:
834 1037 case "end":
835 1038 return _context5.stop();
836 1039 }
837 1040 }
838   - }, _callee5, null, [[12, 28, 31, 34]]);
  1041 + }, _callee5, null, [[20, 36, 39, 42]]);
839 1042 }))();
840 1043 }
841 1044 }
... ...
绿纤uni-app/unpackage/dist/dev/mp-weixin/pages/attendance-punch/attendance-punch.wxml
1   -<view class="att-page data-v-f6f89274"><view class="att-header data-v-f6f89274"><view class="att-clock data-v-f6f89274">{{clockTime}}</view><view class="att-meta data-v-f6f89274"><text class="att-date data-v-f6f89274">{{todayText}}</text><text class="att-week data-v-f6f89274">{{weekdayText}}</text></view><view class="att-user data-v-f6f89274">{{displayName}}</view><block wx:if="{{ruleTimeText}}"><view class="att-rule data-v-f6f89274"><text class="att-rule-label data-v-f6f89274">今日班次</text><text class="att-rule-val data-v-f6f89274">{{ruleTimeText}}</text></view></block></view><view class="att-body data-v-f6f89274"><view class="att-card att-seg-card data-v-f6f89274"><view class="att-seg data-v-f6f89274"><view data-event-opts="{{[['tap',[['setPunchType',[1]]]]]}}" class="{{['att-seg-item','data-v-f6f89274',(punchType===1)?'is-active':'']}}" bindtap="__e">正常</view><view data-event-opts="{{[['tap',[['setPunchType',[2]]]]]}}" class="{{['att-seg-item','data-v-f6f89274',(punchType===2)?'is-active':'']}}" bindtap="__e">外勤</view></view></view><view class="att-card att-punch-card data-v-f6f89274"><view data-event-opts="{{[['tap',[['onMainPunch',['$event']]]]]}}" class="{{['att-big-btn','data-v-f6f89274',(allPunchDone)?'is-all-done':'',(!canPressMainButton)?'is-disabled':'']}}" bindtap="__e"><block wx:if="{{allPunchDone}}"><u-icon vue-id="0bf3a300-1" name="checkmark-circle-fill" color="#ffffff" size="52" class="data-v-f6f89274" bind:__l="__l"></u-icon><text class="att-big-done-text data-v-f6f89274">今日打卡已完成</text></block><block wx:else><text class="att-big-title data-v-f6f89274">{{mainButtonTitle}}</text><text class="att-big-sub data-v-f6f89274">{{mainButtonSub}}</text></block></view><block wx:if="{{nextPunchDirection&&!allPunchDone}}"><view class="att-next-hint data-v-f6f89274">{{'当前应打:'+(nextPunchDirection===1?'上班卡':'下班卡')+''}}</view></block></view><view class="att-card att-loc-card data-v-f6f89274"><u-icon vue-id="0bf3a300-2" name="map-fill" color="#43a047" size="22" class="data-v-f6f89274" bind:__l="__l"></u-icon><view class="att-loc-main data-v-f6f89274"><text class="att-loc-text data-v-f6f89274">{{locationText}}</text><text class="att-loc-tip data-v-f6f89274">{{fenceStatusText}}</text><view class="att-loc-actions data-v-f6f89274"><block wx:if="{{canViewFenceMap}}"><text data-event-opts="{{[['tap',[['openFenceMap',['$event']]]]]}}" class="att-loc-action att-loc-action--primary data-v-f6f89274" catchtap="__e">查看打卡范围</text></block><text data-event-opts="{{[['tap',[['refreshLocation',['$event']]]]]}}" class="att-loc-action data-v-f6f89274" catchtap="__e">重新定位</text></view></view><u-icon vue-id="0bf3a300-3" name="arrow-right" color="#c8c9cc" size="18" class="data-v-f6f89274" bind:__l="__l"></u-icon></view><view class="att-card data-v-f6f89274"><view class="att-card-head data-v-f6f89274"><text class="att-card-title data-v-f6f89274">今日记录</text><block wx:if="{{detail&&detail.statusText}}"><view class="att-status-tag data-v-f6f89274">{{detail.statusText}}</view></block></view><block wx:if="{{detail}}"><view class="att-timeline data-v-f6f89274"><view class="att-tl-item data-v-f6f89274"><view class="{{['att-tl-dot','data-v-f6f89274',punchInDone?'is-on':'']}}"></view><view class="att-tl-line data-v-f6f89274"></view><view class="att-tl-body data-v-f6f89274"><view class="att-tl-row data-v-f6f89274"><text class="att-tl-name data-v-f6f89274">上班</text><text class="att-tl-time data-v-f6f89274">{{detail.punchIn&&detail.punchIn.time?detail.punchIn.time:'未打卡'}}</text></view></view></view><view class="att-tl-item att-tl-item--last data-v-f6f89274"><view class="{{['att-tl-dot','data-v-f6f89274',punchOutDone?'is-on':'']}}"></view><view class="att-tl-body data-v-f6f89274"><view class="att-tl-row data-v-f6f89274"><text class="att-tl-name data-v-f6f89274">下班</text><text class="att-tl-time data-v-f6f89274">{{detail.punchOut&&detail.punchOut.time?detail.punchOut.time:'未打卡'}}</text></view></view></view></view></block><block wx:else><block wx:if="{{!loading}}"><view class="att-empty data-v-f6f89274"><text class="data-v-f6f89274">今日尚无记录,打卡后将显示在此</text></view></block></block></view><view class="att-card att-more-card data-v-f6f89274"><view class="att-more-title data-v-f6f89274">打卡照片(选填)</view><view class="att-upload-wrap data-v-f6f89274"><u-upload vue-id="0bf3a300-4" fileList="{{photoFileList}}" name="photo" maxCount="{{1}}" accept="image" width="120" height="120" data-event-opts="{{[['^afterRead',[['afterReadPhoto']]],['^delete',[['deletePhoto']]]]}}" bind:afterRead="__e" bind:delete="__e" class="data-v-f6f89274" bind:__l="__l"></u-upload></view><block wx:if="{{punchType===2}}"><view class="att-more-title att-more-title--second data-v-f6f89274">外勤说明(选填)</view><u-input bind:input="__e" vue-id="0bf3a300-5" placeholder="请填写外勤事由(选填)" border="none" customStyle="{{({background:'#f1f8f4',padding:'16rpx 20rpx',borderRadius:'12rpx'})}}" value="{{remark}}" data-event-opts="{{[['^input',[['__set_model',['','remark','$event',[]]]]]]}}" class="data-v-f6f89274" bind:__l="__l"></u-input></block></view><view class="att-safe data-v-f6f89274"></view></view><block wx:if="{{loading}}"><view class="att-loading-mask data-v-f6f89274"><u-loading-icon vue-id="0bf3a300-6" mode="circle" size="28" color="#43a047" class="data-v-f6f89274" bind:__l="__l"></u-loading-icon><text class="att-loading-txt data-v-f6f89274">加载中</text></view></block></view>
2 1 \ No newline at end of file
  2 +<view class="att-page data-v-f6f89274"><view class="att-header data-v-f6f89274"><view class="att-clock data-v-f6f89274">{{clockTime}}</view><view class="att-meta data-v-f6f89274"><text class="att-date data-v-f6f89274">{{todayText}}</text><text class="att-week data-v-f6f89274">{{weekdayText}}</text></view><view class="att-user data-v-f6f89274">{{displayName}}</view><block wx:if="{{ruleTimeText}}"><view class="att-rule data-v-f6f89274"><text class="att-rule-label data-v-f6f89274">今日班次</text><text class="att-rule-val data-v-f6f89274">{{ruleTimeText}}</text></view></block></view><view class="att-body data-v-f6f89274"><view class="att-card att-seg-card data-v-f6f89274"><view class="att-seg data-v-f6f89274"><view data-event-opts="{{[['tap',[['setPunchType',[1]]]]]}}" class="{{['att-seg-item','data-v-f6f89274',(punchType===1)?'is-active':'']}}" bindtap="__e">正常 </view><view data-event-opts="{{[['tap',[['setPunchType',[2]]]]]}}" class="{{['att-seg-item','data-v-f6f89274',(punchType===2)?'is-active':'']}}" bindtap="__e">外勤 </view></view></view><view class="att-card att-punch-card data-v-f6f89274"><view data-event-opts="{{[['tap',[['onMainPunch',['$event']]]]]}}" class="{{['att-big-btn','data-v-f6f89274',(allPunchDone)?'is-all-done':'',(!canPressMainButton)?'is-disabled':'']}}" bindtap="__e"><block wx:if="{{allPunchDone}}"><u-icon vue-id="0bf3a300-1" name="checkmark-circle-fill" color="#ffffff" size="52" class="data-v-f6f89274" bind:__l="__l"></u-icon><text class="att-big-done-text data-v-f6f89274">今日打卡已完成</text></block><block wx:else><text class="att-big-title data-v-f6f89274">{{mainButtonTitle}}</text><text class="att-big-sub data-v-f6f89274">{{mainButtonSub}}</text></block></view><block wx:if="{{nextPunchDirection&&!allPunchDone}}"><view class="att-next-hint data-v-f6f89274">{{'当前应打:'+(nextPunchDirection===1?'上班卡':'下班卡')+''}}</view></block></view><view class="att-card att-loc-card data-v-f6f89274"><u-icon vue-id="0bf3a300-2" name="map-fill" color="#43a047" size="22" class="data-v-f6f89274" bind:__l="__l"></u-icon><view class="att-loc-main data-v-f6f89274"><text class="att-loc-text data-v-f6f89274">{{locationText}}</text><text class="att-loc-tip data-v-f6f89274">{{fenceStatusText}}</text><view class="att-loc-actions data-v-f6f89274"><block wx:if="{{canViewFenceMap}}"><text data-event-opts="{{[['tap',[['openFenceMap',['$event']]]]]}}" class="att-loc-action att-loc-action--primary data-v-f6f89274" catchtap="__e">查看打卡范围</text></block><text data-event-opts="{{[['tap',[['refreshLocation',['$event']]]]]}}" class="att-loc-action data-v-f6f89274" catchtap="__e">重新定位</text></view></view><u-icon vue-id="0bf3a300-3" name="arrow-right" color="#c8c9cc" size="18" class="data-v-f6f89274" bind:__l="__l"></u-icon></view><block wx:if="{{$root.m0===1}}"><view class="att-card att-loc-card att-wifi-card data-v-f6f89274"><u-icon vue-id="0bf3a300-4" name="wifi" color="#43a047" size="22" class="data-v-f6f89274" bind:__l="__l"></u-icon><view class="att-loc-main data-v-f6f89274"><text class="att-loc-text data-v-f6f89274">{{wifiMainText}}</text><text class="att-loc-tip data-v-f6f89274">{{wifiTipText}}</text><view class="att-loc-actions data-v-f6f89274"><text data-event-opts="{{[['tap',[['refreshWifiInfo',['$event']]]]]}}" class="att-loc-action data-v-f6f89274" catchtap="__e">刷新 Wi-Fi</text></view></view><u-icon vue-id="0bf3a300-5" name="arrow-right" color="#c8c9cc" size="18" class="data-v-f6f89274" bind:__l="__l"></u-icon></view></block><view class="att-card data-v-f6f89274"><view class="att-card-head data-v-f6f89274"><text class="att-card-title data-v-f6f89274">今日记录</text><block wx:if="{{detail&&detail.statusText}}"><view class="att-status-tag data-v-f6f89274">{{detail.statusText}}</view></block></view><block wx:if="{{detail}}"><view class="att-timeline data-v-f6f89274"><view class="att-tl-item data-v-f6f89274"><view class="{{['att-tl-dot','data-v-f6f89274',punchInDone?'is-on':'']}}"></view><view class="att-tl-line data-v-f6f89274"></view><view class="att-tl-body data-v-f6f89274"><view class="att-tl-row data-v-f6f89274"><text class="att-tl-name data-v-f6f89274">上班</text><text class="att-tl-time data-v-f6f89274">{{detail.punchIn&&detail.punchIn.time?detail.punchIn.time:'未打卡'}}</text></view></view></view><view class="att-tl-item att-tl-item--last data-v-f6f89274"><view class="{{['att-tl-dot','data-v-f6f89274',punchOutDone?'is-on':'']}}"></view><view class="att-tl-body data-v-f6f89274"><view class="att-tl-row data-v-f6f89274"><text class="att-tl-name data-v-f6f89274">下班</text><text class="att-tl-time data-v-f6f89274">{{detail.punchOut&&detail.punchOut.time?detail.punchOut.time:'未打卡'}}</text></view></view></view></view></block><block wx:else><block wx:if="{{!loading}}"><view class="att-empty data-v-f6f89274"><text class="data-v-f6f89274">今日尚无记录,打卡后将显示在此</text></view></block></block></view><view class="att-card att-more-card data-v-f6f89274"><view class="att-more-title data-v-f6f89274">打卡照片(选填)</view><view class="att-upload-wrap data-v-f6f89274"><u-upload vue-id="0bf3a300-6" fileList="{{photoFileList}}" name="photo" maxCount="{{1}}" accept="image" width="120" height="120" data-event-opts="{{[['^afterRead',[['afterReadPhoto']]],['^delete',[['deletePhoto']]]]}}" bind:afterRead="__e" bind:delete="__e" class="data-v-f6f89274" bind:__l="__l"></u-upload></view><block wx:if="{{punchType===2}}"><view class="att-more-title att-more-title--second data-v-f6f89274">外勤说明(选填)</view><u-input bind:input="__e" vue-id="0bf3a300-7" placeholder="请填写外勤事由(选填)" border="none" customStyle="{{({background:'#f1f8f4',padding:'16rpx 20rpx',borderRadius:'12rpx'})}}" value="{{remark}}" data-event-opts="{{[['^input',[['__set_model',['','remark','$event',[]]]]]]}}" class="data-v-f6f89274" bind:__l="__l"></u-input></block></view><view class="att-safe data-v-f6f89274"></view></view><block wx:if="{{loading}}"><view class="att-loading-mask data-v-f6f89274"><u-loading-icon vue-id="0bf3a300-8" mode="circle" size="28" color="#43a047" class="data-v-f6f89274" bind:__l="__l"></u-loading-icon><text class="att-loading-txt data-v-f6f89274">加载中</text></view></block></view>
3 3 \ No newline at end of file
... ...
绿纤uni-app/unpackage/dist/dev/mp-weixin/pagesA/my-application-list/my-application-list.js
... ... @@ -219,11 +219,37 @@ function _objectSpread(target) { for (var i = 1; i &lt; arguments.length; i++) { va
219 219 //
220 220 //
221 221 //
  222 +//
  223 +//
  224 +//
  225 +//
  226 +//
  227 +//
  228 +//
  229 +//
  230 +//
  231 +//
  232 +//
  233 +//
222 234 var _default = {
223 235 data: function data() {
224 236 return {
225 237 loading: false,
226 238 list: [],
  239 + statusFilter: 'all',
  240 + statusTabs: [{
  241 + key: 'all',
  242 + label: '全部'
  243 + }, {
  244 + key: 'running',
  245 + label: '审核中'
  246 + }, {
  247 + key: 'passed',
  248 + label: '已通过'
  249 + }, {
  250 + key: 'rejected',
  251 + label: '不通过'
  252 + }],
227 253 query: {
228 254 currentPage: 1,
229 255 pageSize: 20,
... ... @@ -234,9 +260,17 @@ var _default = {
234 260 };
235 261 },
236 262 computed: {
  263 + filteredList: function filteredList() {
  264 + if (!Array.isArray(this.list)) return [];
  265 + var st = this.getStatusFilterNumber(this.statusFilter);
  266 + if (st === null) return this.list;
  267 + return this.list.filter(function (item) {
  268 + return Number(item.status) === st;
  269 + });
  270 + },
237 271 displayList: function displayList() {
238 272 var _this = this;
239   - return (this.list || []).map(function (item) {
  273 + return (this.filteredList || []).map(function (item) {
240 274 return _objectSpread(_objectSpread({}, item), {}, {
241 275 statusClassName: _this.statusClass(item.status),
242 276 statusText: _this.getStatusText(item.status),
... ... @@ -305,6 +339,11 @@ var _default = {
305 339 }, _callee, null, [[1, 11, 16, 20]]);
306 340 }))();
307 341 },
  342 + changeStatusFilter: function changeStatusFilter(key) {
  343 + if (this.statusFilter === key) return;
  344 + this.statusFilter = key;
  345 + this.refresh();
  346 + },
308 347 refresh: function refresh() {
309 348 this.query.currentPage = 1;
310 349 this.total = 0;
... ... @@ -341,6 +380,15 @@ var _default = {
341 380 };
342 381 return map[Number(status)] || 'status-default';
343 382 },
  383 + // 业务状态:与 getStatusText / 后端 FlowTaskEntity.status 对齐
  384 + // all: 全部;running: 待审核;passed: 已通过;rejected: 已驳回(不通过)
  385 + getStatusFilterNumber: function getStatusFilterNumber(key) {
  386 + if (key === 'all') return null;
  387 + if (key === 'running') return 1;
  388 + if (key === 'passed') return 2;
  389 + if (key === 'rejected') return 3;
  390 + return null;
  391 + },
344 392 getUrgentText: function getUrgentText(val) {
345 393 var map = {
346 394 1: '普通',
... ...
绿纤uni-app/unpackage/dist/dev/mp-weixin/pagesA/my-application-list/my-application-list.wxml
1   -<view class="page data-v-3bb121e5"><view class="header-card data-v-3bb121e5"><view class="header-title data-v-3bb121e5">我的申请</view><view class="header-sub data-v-3bb121e5">查看我发起的流程申请,支持进入详情查看表单与审批进度</view></view><block wx:if="{{$root.g0}}"><view class="state-card data-v-3bb121e5"><text class="state-text data-v-3bb121e5">加载中...</text></view></block><block wx:else><block wx:if="{{$root.g1}}"><view class="state-card empty-card data-v-3bb121e5"><text class="empty-icon data-v-3bb121e5">无</text><text class="state-text data-v-3bb121e5">暂无申请记录</text></view></block><block wx:else><view class="list-wrap data-v-3bb121e5"><block wx:for="{{displayList}}" wx:for-item="item" wx:for-index="__i0__" wx:key="id"><view data-event-opts="{{[['tap',[['goDetail',['$0'],[[['displayList','id',item.id]]]]]]]}}" class="apply-card data-v-3bb121e5" bindtap="__e"><view class="card-head data-v-3bb121e5"><view class="title-wrap data-v-3bb121e5"><text class="apply-title data-v-3bb121e5">{{item.fullName||'无标题'}}</text><text class="apply-flow data-v-3bb121e5">{{item.flowName||item.flowCode||'流程'}}</text></view><view class="{{['status-tag','data-v-3bb121e5',item.statusClassName]}}">{{''+item.statusText+''}}</view></view><view class="meta-row data-v-3bb121e5"><text class="meta-label data-v-3bb121e5">发起时间</text><text class="meta-value data-v-3bb121e5">{{item.startTimeText}}</text></view><view class="meta-row data-v-3bb121e5"><text class="meta-label data-v-3bb121e5">当前节点</text><text class="meta-value data-v-3bb121e5">{{item.thisStep||'-'}}</text></view><view class="meta-row data-v-3bb121e5"><text class="meta-label data-v-3bb121e5">紧急程度</text><text class="meta-value data-v-3bb121e5">{{item.urgentText}}</text></view><view class="progress-row data-v-3bb121e5"><view class="progress-track data-v-3bb121e5"><view class="progress-inner data-v-3bb121e5" style="{{(item.progressWidthStyle)}}"></view></view><text class="progress-text data-v-3bb121e5">{{item.progressText}}</text></view></view></block></view></block></block><block wx:if="{{$root.g2}}"><view class="footer-tip data-v-3bb121e5">下拉可刷新,点击卡片查看详情</view></block></view>
2 1 \ No newline at end of file
  2 +<view class="page data-v-3bb121e5"><view class="header-card data-v-3bb121e5"><view class="header-title data-v-3bb121e5">我的申请</view><view class="header-sub data-v-3bb121e5">查看我发起的流程申请,支持进入详情查看表单与审批进度</view></view><view class="filter-bar data-v-3bb121e5"><block wx:for="{{statusTabs}}" wx:for-item="tab" wx:for-index="__i0__" wx:key="key"><view data-event-opts="{{[['tap',[['changeStatusFilter',['$0'],[[['statusTabs','key',tab.key,'key']]]]]]]}}" class="{{['filter-item','data-v-3bb121e5',statusFilter===tab.key?'filter-item--active':'']}}" bindtap="__e">{{''+tab.label+''}}</view></block></view><block wx:if="{{$root.g0}}"><view class="state-card data-v-3bb121e5"><text class="state-text data-v-3bb121e5">加载中...</text></view></block><block wx:else><block wx:if="{{$root.g1}}"><view class="state-card empty-card data-v-3bb121e5"><text class="empty-icon data-v-3bb121e5">无</text><text class="state-text data-v-3bb121e5">暂无申请记录</text></view></block><block wx:else><view class="list-wrap data-v-3bb121e5"><block wx:for="{{displayList}}" wx:for-item="item" wx:for-index="__i1__" wx:key="id"><view data-event-opts="{{[['tap',[['goDetail',['$0'],[[['displayList','id',item.id]]]]]]]}}" class="apply-card data-v-3bb121e5" bindtap="__e"><view class="card-head data-v-3bb121e5"><view class="title-wrap data-v-3bb121e5"><text class="apply-title data-v-3bb121e5">{{item.fullName||'无标题'}}</text><text class="apply-flow data-v-3bb121e5">{{item.flowName||item.flowCode||'流程'}}</text></view><view class="{{['status-tag','data-v-3bb121e5',item.statusClassName]}}">{{''+item.statusText+''}}</view></view><view class="meta-row data-v-3bb121e5"><text class="meta-label data-v-3bb121e5">发起时间</text><text class="meta-value data-v-3bb121e5">{{item.startTimeText}}</text></view><view class="meta-row data-v-3bb121e5"><text class="meta-label data-v-3bb121e5">当前节点</text><text class="meta-value data-v-3bb121e5">{{item.thisStep||'-'}}</text></view><view class="meta-row data-v-3bb121e5"><text class="meta-label data-v-3bb121e5">紧急程度</text><text class="meta-value data-v-3bb121e5">{{item.urgentText}}</text></view><view class="progress-row data-v-3bb121e5"><view class="progress-track data-v-3bb121e5"><view class="progress-inner data-v-3bb121e5" style="{{(item.progressWidthStyle)}}"></view></view><text class="progress-text data-v-3bb121e5">{{item.progressText}}</text></view></view></block></view></block></block><block wx:if="{{$root.g2}}"><view class="footer-tip data-v-3bb121e5">下拉可刷新,点击卡片查看详情</view></block></view>
3 3 \ No newline at end of file
... ...
绿纤uni-app/unpackage/dist/dev/mp-weixin/pagesA/my-application-list/my-application-list.wxss
... ... @@ -30,6 +30,28 @@
30 30 box-sizing: border-box;
31 31 background: linear-gradient(180deg, #e8f5e9 0%, #f7fff8 100%);
32 32 }
  33 +.filter-bar.data-v-3bb121e5 {
  34 + display: flex;
  35 + gap: 16rpx;
  36 + padding: 0 12rpx;
  37 + margin-bottom: 22rpx;
  38 +}
  39 +.filter-item.data-v-3bb121e5 {
  40 + flex: 1;
  41 + text-align: center;
  42 + padding: 14rpx 0;
  43 + border-radius: 999rpx;
  44 + background: rgba(255, 255, 255, 0.7);
  45 + color: #7a8b7b;
  46 + font-size: 24rpx;
  47 + font-weight: 600;
  48 + border: 1px solid rgba(46, 125, 50, 0.12);
  49 +}
  50 +.filter-item--active.data-v-3bb121e5 {
  51 + background: #e8f5e9;
  52 + color: #2e7d32;
  53 + border-color: rgba(46, 125, 50, 0.35);
  54 +}
33 55 .header-card.data-v-3bb121e5,
34 56 .state-card.data-v-3bb121e5,
35 57 .apply-card.data-v-3bb121e5 {
... ...
项目文档相关/docs/数据库说明.md
... ... @@ -56,6 +56,11 @@
56 56 - `longitude` (DECIMAL): 经度
57 57 - `latitude` (DECIMAL): 纬度
58 58 - `fence_polygons` (JSON): 电子围栏多边形坐标,格式 `[[{lng,lat},...]]`
  59 + - `F_AttendanceCheckFence` (INT,可空): 正常打卡是否启用电子围栏校验(1-是,0-否;**可空**:空表示沿用历史默认「有围栏则校验」)
  60 + - `F_AttendanceCheckWifi` (INT,可空): 正常打卡是否启用 Wi-Fi 校验(1-是,0-否)
  61 + - `F_AttendanceWifiPairs` (VARCHAR): Wi-Fi 成对白名单,JSON 数组,如 `[{"ssid":"门店5G","bssid":"aa:bb:cc:dd:ee:ff"}]`
  62 + - `F_AttendanceWifiVerifyPair` (INT,可空,默认 0): **是否校验 SSID 与 BSSID 为同一 AP**。`1`:端上能拿到 BSSID 时必须与某行 **SSID+BSSID 同时一致**;端上 **拿不到 BSSID** 时须 **在电子围栏内** 且 SSID 与某行一致(防同名热点)。`0`:不强制同 AP,只要当前 SSID 或 BSSID **命中任意一行中的字段** 即可(仍按行维护,但校验为「或」)
  63 + - ~~`F_AttendanceWifiSsids` / `F_AttendanceWifiBssids`~~:已删除,统一使用 `F_AttendanceWifiPairs`(脚本:`项目文档相关/sql/2026-4-3/门店考勤_围栏与WiFi打卡配置.sql`)
59 64 - `F_BusinessHours` (TEXT): 营业时间设置
60 65 - `F_TrafficTips` (TEXT): 交通提示
61 66 - **人员信息**: `BASE_USER` (系统用户表,包含门店ID等扩展字段)
... ... @@ -161,7 +166,7 @@
161 166 - `F_PunchOutLongitude` / `F_PunchOutLatitude` / `F_PunchOutAddress`: 下班打卡定位与地址
162 167 - `F_PunchInPhotoUrl` / `F_PunchOutPhotoUrl`: 上下班打卡照片
163 168 - `F_IsPunchInFenceValid` / `F_IsPunchOutFenceValid`: 上下班围栏校验结果(1围栏内,0围栏外,null未校验)
164   - - `F_Status`: 打卡状态(1正常,2迟到,3休息,4请假,5病假,6缺卡,7旷工
  169 + - `F_Status`: 打卡状态(1正常,2迟到,3休息,4请假,5病假,6缺卡,7旷工,8待考勤;8 表示考勤日晚于当前日期且尚无打卡,界面展示「暂无打卡」,不计入缺卡类异常统计
165 170 - `F_LateMinutes`: 迟到分钟数
166 171 - `F_EarlyLeaveMinutes`: 早退分钟数
167 172 - `F_IsManual`: 是否手动补录
... ...
项目文档相关/sql/2026-4-3/门店考勤_WiFi_BSSID白名单列.sql 0 → 100644
  1 +-- 已合并入「门店考勤_围栏与WiFi打卡配置.sql」。
  2 +-- 独立 SSID/BSSID 列已废弃并删除,请只执行:门店考勤_围栏与WiFi打卡配置.sql
  3 +
  4 +SELECT 1 AS note_use_main_script_instead;
... ...
项目文档相关/sql/2026-4-3/门店考勤_围栏与WiFi打卡配置.sql 0 → 100644
  1 +-- 门店正常打卡:围栏 + Wi-Fi 成对配置(SSID+BSSID)
  2 +-- 可重复执行。会删除已废弃列 F_AttendanceWifiSsids、F_AttendanceWifiBssids(数据请先迁移到 F_AttendanceWifiPairs)
  3 +-- 执行前:USE 你的库名;
  4 +
  5 +DELIMITER $$
  6 +
  7 +DROP PROCEDURE IF EXISTS `sp_patch_lq_mdxx_attendance_wifi`$$
  8 +
  9 +CREATE PROCEDURE `sp_patch_lq_mdxx_attendance_wifi`()
  10 +BEGIN
  11 + DECLARE dbname VARCHAR(64);
  12 + SET dbname = DATABASE();
  13 +
  14 + -- 1) 基础列:围栏开关、Wi-Fi 开关
  15 + IF NOT EXISTS (
  16 + SELECT 1 FROM information_schema.COLUMNS
  17 + WHERE TABLE_SCHEMA = dbname AND TABLE_NAME = 'lq_mdxx' AND COLUMN_NAME = 'F_AttendanceCheckFence'
  18 + ) THEN
  19 + ALTER TABLE `lq_mdxx`
  20 + ADD COLUMN `F_AttendanceCheckFence` int NULL DEFAULT NULL
  21 + COMMENT '正常打卡是否启用围栏校验:1是 0否;NULL 表示沿用旧逻辑(有围栏则校验)'
  22 + AFTER `fence_polygons`;
  23 + END IF;
  24 +
  25 + IF NOT EXISTS (
  26 + SELECT 1 FROM information_schema.COLUMNS
  27 + WHERE TABLE_SCHEMA = dbname AND TABLE_NAME = 'lq_mdxx' AND COLUMN_NAME = 'F_AttendanceCheckWifi'
  28 + ) THEN
  29 + ALTER TABLE `lq_mdxx`
  30 + ADD COLUMN `F_AttendanceCheckWifi` int NULL DEFAULT NULL
  31 + COMMENT '正常打卡是否启用Wi-Fi校验:1是 0否;NULL 视为0'
  32 + AFTER `F_AttendanceCheckFence`;
  33 + END IF;
  34 +
  35 + -- 2) 删除废弃列(独立 SSID / BSSID JSON 列,已由 F_AttendanceWifiPairs 替代)
  36 + IF EXISTS (
  37 + SELECT 1 FROM information_schema.COLUMNS
  38 + WHERE TABLE_SCHEMA = dbname AND TABLE_NAME = 'lq_mdxx' AND COLUMN_NAME = 'F_AttendanceWifiBssids'
  39 + ) THEN
  40 + ALTER TABLE `lq_mdxx` DROP COLUMN `F_AttendanceWifiBssids`;
  41 + END IF;
  42 +
  43 + IF EXISTS (
  44 + SELECT 1 FROM information_schema.COLUMNS
  45 + WHERE TABLE_SCHEMA = dbname AND TABLE_NAME = 'lq_mdxx' AND COLUMN_NAME = 'F_AttendanceWifiSsids'
  46 + ) THEN
  47 + ALTER TABLE `lq_mdxx` DROP COLUMN `F_AttendanceWifiSsids`;
  48 + END IF;
  49 +
  50 + -- 3) 成对配置列(加在 Wi-Fi 开关后;此时已不存在旧列)
  51 + IF NOT EXISTS (
  52 + SELECT 1 FROM information_schema.COLUMNS
  53 + WHERE TABLE_SCHEMA = dbname AND TABLE_NAME = 'lq_mdxx' AND COLUMN_NAME = 'F_AttendanceWifiPairs'
  54 + ) THEN
  55 + ALTER TABLE `lq_mdxx`
  56 + ADD COLUMN `F_AttendanceWifiPairs` varchar(4000) NULL DEFAULT NULL
  57 + COMMENT 'Wi-Fi成对配置 JSON [{"ssid":"","bssid":""}]'
  58 + AFTER `F_AttendanceCheckWifi`;
  59 + END IF;
  60 +
  61 + IF NOT EXISTS (
  62 + SELECT 1 FROM information_schema.COLUMNS
  63 + WHERE TABLE_SCHEMA = dbname AND TABLE_NAME = 'lq_mdxx' AND COLUMN_NAME = 'F_AttendanceWifiVerifyPair'
  64 + ) THEN
  65 + ALTER TABLE `lq_mdxx`
  66 + ADD COLUMN `F_AttendanceWifiVerifyPair` int NULL DEFAULT 0
  67 + COMMENT '1=校验SSID与BSSID为同一AP;0=SSID或BSSID命中任一行即可'
  68 + AFTER `F_AttendanceWifiPairs`;
  69 + END IF;
  70 +END$$
  71 +
  72 +DELIMITER ;
  73 +
  74 +CALL `sp_patch_lq_mdxx_attendance_wifi`();
  75 +
  76 +DROP PROCEDURE IF EXISTS `sp_patch_lq_mdxx_attendance_wifi`;
... ...