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,7 +44,7 @@
44 </div> 44 </div>
45 <div class="overview-card info"> 45 <div class="overview-card info">
46 <div class="overview-card__label">录入方式</div> 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 </div> 48 </div>
49 </div> 49 </div>
50 50
@@ -54,7 +54,7 @@ @@ -54,7 +54,7 @@
54 <span>上班打卡</span> 54 <span>上班打卡</span>
55 <div class="punch-card__actions"> 55 <div class="punch-card__actions">
56 <el-tag size="mini" :type="detail.punchIn && detail.punchIn.type === 2 ? 'warning' : 'success'"> 56 <el-tag size="mini" :type="detail.punchIn && detail.punchIn.type === 2 ? 'warning' : 'success'">
57 - {{ detail.punchIn ? detail.punchIn.typeText || '未打卡' : '未打卡' }} 57 + {{ punchInTypeLabel }}
58 </el-tag> 58 </el-tag>
59 <el-button size="mini" type="text" :disabled="!hasCoordinate(detail.punchIn)" 59 <el-button size="mini" type="text" :disabled="!hasCoordinate(detail.punchIn)"
60 @click="openMap(detail.punchIn, '上班打卡地点')">查看地图</el-button> 60 @click="openMap(detail.punchIn, '上班打卡地点')">查看地图</el-button>
@@ -63,7 +63,7 @@ @@ -63,7 +63,7 @@
63 <div class="punch-card__content"> 63 <div class="punch-card__content">
64 <div class="field-item"> 64 <div class="field-item">
65 <label>打卡时间</label> 65 <label>打卡时间</label>
66 - <span>{{ detail.punchIn ? detail.punchIn.time || '未打卡' : '未打卡' }}</span> 66 + <span>{{ punchInTimeLabel }}</span>
67 </div> 67 </div>
68 <div class="field-item"> 68 <div class="field-item">
69 <label>打卡地址</label> 69 <label>打卡地址</label>
@@ -89,7 +89,7 @@ @@ -89,7 +89,7 @@
89 <span>下班打卡</span> 89 <span>下班打卡</span>
90 <div class="punch-card__actions"> 90 <div class="punch-card__actions">
91 <el-tag size="mini" :type="detail.punchOut && detail.punchOut.type === 2 ? 'warning' : 'success'"> 91 <el-tag size="mini" :type="detail.punchOut && detail.punchOut.type === 2 ? 'warning' : 'success'">
92 - {{ detail.punchOut ? detail.punchOut.typeText || '未打卡' : '未打卡' }} 92 + {{ punchOutTypeLabel }}
93 </el-tag> 93 </el-tag>
94 <el-button size="mini" type="text" :disabled="!hasCoordinate(detail.punchOut)" 94 <el-button size="mini" type="text" :disabled="!hasCoordinate(detail.punchOut)"
95 @click="openMap(detail.punchOut, '下班打卡地点')">查看地图</el-button> 95 @click="openMap(detail.punchOut, '下班打卡地点')">查看地图</el-button>
@@ -98,7 +98,7 @@ @@ -98,7 +98,7 @@
98 <div class="punch-card__content"> 98 <div class="punch-card__content">
99 <div class="field-item"> 99 <div class="field-item">
100 <label>打卡时间</label> 100 <label>打卡时间</label>
101 - <span>{{ detail.punchOut ? detail.punchOut.time || '未打卡' : '未打卡' }}</span> 101 + <span>{{ punchOutTimeLabel }}</span>
102 </div> 102 </div>
103 <div class="field-item"> 103 <div class="field-item">
104 <label>打卡地址</label> 104 <label>打卡地址</label>
@@ -120,7 +120,7 @@ @@ -120,7 +120,7 @@
120 </div> 120 </div>
121 </div> 121 </div>
122 122
123 - <el-card shadow="never" class="supplement-card"> 123 + <el-card v-if="hasSupplementInfo" shadow="never" class="supplement-card">
124 <div slot="header" class="supplement-card__header supplement-card__header--row"> 124 <div slot="header" class="supplement-card__header supplement-card__header--row">
125 <span>补卡信息</span> 125 <span>补卡信息</span>
126 <el-button v-if="canShowCancelWorkflowSupplement" type="danger" size="mini" plain 126 <el-button v-if="canShowCancelWorkflowSupplement" type="danger" size="mini" plain
@@ -151,7 +151,7 @@ @@ -151,7 +151,7 @@
151 </div> 151 </div>
152 </el-card> 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 class="supplement-card workflow-card"> 155 class="supplement-card workflow-card">
156 <div slot="header" class="supplement-card__header"> 156 <div slot="header" class="supplement-card__header">
157 <span>关联流程</span> 157 <span>关联流程</span>
@@ -176,6 +176,17 @@ @@ -176,6 +176,17 @@
176 <span>当前为{{ detail.statusText }}状态,暂无关联流程记录</span> 176 <span>当前为{{ detail.statusText }}状态,暂无关联流程记录</span>
177 </div> 177 </div>
178 </el-card> 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 </template> 190 </template>
180 191
181 <el-empty v-else :image-size="72" description="暂无考勤详情" /> 192 <el-empty v-else :image-size="72" description="暂无考勤详情" />
@@ -232,6 +243,7 @@ export default { @@ -232,6 +243,7 @@ export default {
232 case 2: 243 case 2:
233 return 'warning' 244 return 'warning'
234 case 3: 245 case 3:
  246 + case 8:
235 return 'info' 247 return 'info'
236 default: 248 default:
237 return 'danger' 249 return 'danger'
@@ -240,12 +252,78 @@ export default { @@ -240,12 +252,78 @@ export default {
240 hasRelatedWorkflows() { 252 hasRelatedWorkflows() {
241 return this.detail && Array.isArray(this.detail.relatedWorkflows) && this.detail.relatedWorkflows.length > 0 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 isLeaveOrSickStatus() { 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 showSupplementButton() { 323 showSupplementButton() {
248 if (!this.detail) return false 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 if (this.detail.isManual === 1) return false 327 if (this.detail.isManual === 1) return false
250 if (this.detail.status === 4 || this.detail.status === 5) return false 328 if (this.detail.status === 4 || this.detail.status === 5) return false
251 const hasIn = !!(this.detail.punchIn && this.detail.punchIn.time) 329 const hasIn = !!(this.detail.punchIn && this.detail.punchIn.time)
@@ -311,6 +389,7 @@ export default { @@ -311,6 +389,7 @@ export default {
311 workflowTagType(type) { 389 workflowTagType(type) {
312 if (type === '请假') return 'danger' 390 if (type === '请假') return 'danger'
313 if (type === '补卡') return 'warning' 391 if (type === '补卡') return 'warning'
  392 + if (type === '销假') return 'success'
314 return 'info' 393 return 'info'
315 }, 394 },
316 canOpenRelatedWorkflow(wf) { 395 canOpenRelatedWorkflow(wf) {
antis-ncc-admin/src/views/attendance-record/index.vue
@@ -7,11 +7,11 @@ @@ -7,11 +7,11 @@
7 <div class="record-hero__eyebrow">Attendance Record</div> 7 <div class="record-hero__eyebrow">Attendance Record</div>
8 <h2 class="record-hero__title">考勤记录</h2> 8 <h2 class="record-hero__title">考勤记录</h2>
9 <p class="record-hero__desc"> 9 <p class="record-hero__desc">
10 - 按月查看员工每日考勤状态(含打卡、补卡及审批通过的请假/病假同步),可查看上下班时间、地点、照片与补卡信息;点击有记录的日期可查看详情并后台补卡。 10 + 按月查看员工每日考勤状态(列表含筛选范围内全部在职员工,无记录日期显示为无);含打卡、补卡及审批通过的请假/病假同步,可查看上下班时间、地点、照片与补卡信息;点击日期可查看详情并后台补卡。
11 </p> 11 </p>
12 </div> 12 </div>
13 <div class="record-hero__actions"> 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 @click="exportMonthReport">导出月度明细</el-button> 15 @click="exportMonthReport">导出月度明细</el-button>
16 <el-button icon="el-icon-refresh-right" @click="refreshPage">刷新</el-button> 16 <el-button icon="el-icon-refresh-right" @click="refreshPage">刷新</el-button>
17 </div> 17 </div>
@@ -22,10 +22,21 @@ @@ -22,10 +22,21 @@
22 22
23 <el-card class="filter-card" shadow="never"> 23 <el-card class="filter-card" shadow="never">
24 <el-form :inline="true" :model="query" class="filter-form" @submit.native.prevent> 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 <el-date-picker v-model="query.month" type="month" value-format="yyyy-MM" format="yyyy-MM" 33 <el-date-picker v-model="query.month" type="month" value-format="yyyy-MM" format="yyyy-MM"
27 placeholder="选择月份" /> 34 placeholder="选择月份" />
28 </el-form-item> 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 <el-form-item label="考勤分组"> 40 <el-form-item label="考勤分组">
30 <el-select v-model="query.attendanceGroupId" clearable filterable placeholder="全部分组"> 41 <el-select v-model="query.attendanceGroupId" clearable filterable placeholder="全部分组">
31 <el-option v-for="item in attendanceGroupOptions" :key="item.id" :label="item.fullName" 42 <el-option v-for="item in attendanceGroupOptions" :key="item.id" :label="item.fullName"
@@ -46,7 +57,7 @@ @@ -46,7 +57,7 @@
46 <div class="summary-card"> 57 <div class="summary-card">
47 <div class="summary-card__label">员工人数</div> 58 <div class="summary-card__label">员工人数</div>
48 <div class="summary-card__value">{{ summary.employeeCount }}</div> 59 <div class="summary-card__value">{{ summary.employeeCount }}</div>
49 - <div class="summary-card__hint">当前筛选条件下有考勤记录的员工数</div> 60 + <div class="summary-card__hint">当前筛选条件下的在职员工数(含当月无考勤记录)</div>
50 </div> 61 </div>
51 <div class="summary-card success"> 62 <div class="summary-card success">
52 <div class="summary-card__label">正常出勤</div> 63 <div class="summary-card__label">正常出勤</div>
@@ -68,7 +79,7 @@ @@ -68,7 +79,7 @@
68 <el-card class="table-card" shadow="never"> 79 <el-card class="table-card" shadow="never">
69 <div slot="header" class="table-card__header"> 80 <div slot="header" class="table-card__header">
70 <div> 81 <div>
71 - <span>月度考勤明细</span> 82 + <span>{{ viewMode === 'month' ? '月度考勤明细' : '周度考勤明细' }}</span>
72 <span class="table-card__sub">{{ headerText }}</span> 83 <span class="table-card__sub">{{ headerText }}</span>
73 </div> 84 </div>
74 <div class="table-card__legend"> 85 <div class="table-card__legend">
@@ -94,10 +105,10 @@ @@ -94,10 +105,10 @@
94 </template> 105 </template>
95 </el-table-column> 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 <template slot-scope="scope"> 109 <template slot-scope="scope">
99 <div class="day-cell" 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 @click="handleOpenDetail(scope.row, day.key, scope.row.dayRecords[day.key])"> 112 @click="handleOpenDetail(scope.row, day.key, scope.row.dayRecords[day.key])">
102 <div class="day-cell__time">{{ scope.row.dayRecords[day.key].timeText }}</div> 113 <div class="day-cell__time">{{ scope.row.dayRecords[day.key].timeText }}</div>
103 <el-tag :type="scope.row.dayRecords[day.key].tagType" size="mini" effect="plain"> 114 <el-tag :type="scope.row.dayRecords[day.key].tagType" size="mini" effect="plain">
@@ -136,6 +147,7 @@ export default { @@ -136,6 +147,7 @@ export default {
136 }, 147 },
137 data() { 148 data() {
138 return { 149 return {
  150 + viewMode: 'month',
139 pageLoading: false, 151 pageLoading: false,
140 tableLoading: false, 152 tableLoading: false,
141 exportLoading: false, 153 exportLoading: false,
@@ -147,7 +159,29 @@ export default { @@ -147,7 +159,29 @@ export default {
147 } 159 }
148 }, 160 },
149 computed: { 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 const base = dayjs(`${this.query.month}-01`) 185 const base = dayjs(`${this.query.month}-01`)
152 const total = base.daysInMonth() 186 const total = base.daysInMonth()
153 return Array.from({ length: total }).map((_, index) => { 187 return Array.from({ length: total }).map((_, index) => {
@@ -157,9 +191,6 @@ export default { @@ -157,9 +191,6 @@ export default {
157 label: `${index + 1}日\n${this.getWeekLabel(date.day())}` 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 created() { 196 created() {
@@ -169,6 +200,7 @@ export default { @@ -169,6 +200,7 @@ export default {
169 createDefaultQuery() { 200 createDefaultQuery() {
170 return { 201 return {
171 month: dayjs().format('YYYY-MM'), 202 month: dayjs().format('YYYY-MM'),
  203 + weekDate: dayjs().format('YYYY-MM-DD'),
172 attendanceGroupId: '', 204 attendanceGroupId: '',
173 keyword: '', 205 keyword: '',
174 currentPage: 1, 206 currentPage: 1,
@@ -228,6 +260,9 @@ export default { @@ -228,6 +260,9 @@ export default {
228 } 260 }
229 }, 261 },
230 async loadData() { 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 if (!this.query.month) { 266 if (!this.query.month) {
232 this.summary = this.createEmptySummary() 267 this.summary = this.createEmptySummary()
233 this.total = 0 268 this.total = 0
@@ -252,7 +287,7 @@ export default { @@ -252,7 +287,7 @@ export default {
252 }, 287 },
253 normalizeRow(item) { 288 normalizeRow(item) {
254 const dayRecordMap = {} 289 const dayRecordMap = {}
255 - this.monthDays.forEach(day => { 290 + this.activeDays.forEach(day => {
256 dayRecordMap[day.key] = this.createEmptyDayRecord() 291 dayRecordMap[day.key] = this.createEmptyDayRecord()
257 }) 292 })
258 ; (item.dayRecords || []).forEach(record => { 293 ; (item.dayRecords || []).forEach(record => {
@@ -271,10 +306,22 @@ export default { @@ -271,10 +306,22 @@ export default {
271 this.loadData() 306 this.loadData()
272 }, 307 },
273 search() { 308 search() {
274 - if (!this.query.month) { 309 + if (this.viewMode === 'month' && !this.query.month) {
275 this.$message.warning('请选择统计月份') 310 this.$message.warning('请选择统计月份')
276 return 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 this.query.currentPage = 1 325 this.query.currentPage = 1
279 this.loadData() 326 this.loadData()
280 }, 327 },
@@ -283,7 +330,7 @@ export default { @@ -283,7 +330,7 @@ export default {
283 this.loadData() 330 this.loadData()
284 }, 331 },
285 handleOpenDetail(row, attendanceDate, dayRecord) { 332 handleOpenDetail(row, attendanceDate, dayRecord) {
286 - if (!dayRecord || dayRecord.statusKey === 'empty') return 333 + // 即使当天为“空白/无记录”,也允许点开详情(弹窗内会显示暂无详情)
287 this.$refs.recordDetailDialog.open({ 334 this.$refs.recordDetailDialog.open({
288 userId: row.userId, 335 userId: row.userId,
289 attendanceDate 336 attendanceDate
@@ -504,7 +551,8 @@ export default { @@ -504,7 +551,8 @@ export default {
504 } 551 }
505 552
506 .status-rest .day-cell__time, 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 color: #909399; 556 color: #909399;
509 } 557 }
510 558
antis-ncc-admin/src/views/lqMdxx/Form.vue
@@ -53,6 +53,62 @@ @@ -53,6 +53,62 @@
53 </div> 53 </div>
54 </el-form-item> 54 </el-form-item>
55 </el-col> 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 <el-col :span="12"> 114 <el-col :span="12">
@@ -98,70 +154,43 @@ @@ -98,70 +154,43 @@
98 </el-col> 154 </el-col>
99 <el-col :span="24"> 155 <el-col :span="24">
100 <el-form-item label="门店图片" prop="storeImages"> 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 </el-form-item> 159 </el-form-item>
103 </el-col> 160 </el-col>
104 <el-col :span="24"> 161 <el-col :span="24">
105 <el-form-item label="门店介绍" prop="storeDescription"> 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 </el-form-item> 165 </el-form-item>
109 </el-col> 166 </el-col>
110 <el-col :span="24"> 167 <el-col :span="24">
111 <el-form-item label="营业时间设置" prop="businessHours"> 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 </el-form-item> 171 </el-form-item>
121 </el-col> 172 </el-col>
122 <el-col :span="24"> 173 <el-col :span="24">
123 <el-form-item label="交通提示" prop="trafficTips"> 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 </el-form-item> 177 </el-form-item>
133 </el-col> 178 </el-col>
134 <el-col :span="24"> 179 <el-col :span="24">
135 <el-form-item label="门店标签" prop="storeTags"> 180 <el-form-item label="门店标签" prop="storeTags">
136 <div class="store-tags-editor"> 181 <div class="store-tags-editor">
137 <div class="store-tags-list"> 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 {{ tag }} 185 {{ tag }}
145 </el-tag> 186 </el-tag>
146 <span v-if="!dataForm.storeTags.length" class="store-tags-empty">暂无标签</span> 187 <span v-if="!dataForm.storeTags.length" class="store-tags-empty">暂无标签</span>
147 </div> 188 </div>
148 <div class="store-tags-action-row" v-if="!isDetail"> 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 @click="showTagInput"> 194 @click="showTagInput">
166 新增标签 195 新增标签
167 </el-button> 196 </el-button>
@@ -186,15 +215,10 @@ @@ -186,15 +215,10 @@
186 </div> 215 </div>
187 </div> 216 </div>
188 <div class="location-search-bar"> 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 </el-input> 222 </el-input>
199 </div> 223 </div>
200 <div id="location-map" class="map-canvas"></div> 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,22 +277,22 @@ const TMAP_WS_KEY = &#39;YRXBZ-NEV6T-K7SXH-VJPMF-G5IQF-F3FCJ&#39;
253 277
254 // JSONP 调用(绕过 CORS,腾讯 WebService API 前端需用 JSONP) 278 // JSONP 调用(绕过 CORS,腾讯 WebService API 前端需用 JSONP)
255 function jsonp(url) { 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 export default { 298 export default {
@@ -296,6 +320,10 @@ export default { @@ -296,6 +320,10 @@ export default {
296 storeTags: [], 320 storeTags: [],
297 businessHours: '', 321 businessHours: '',
298 trafficTips: '', 322 trafficTips: '',
  323 + attendanceCheckFence: 0,
  324 + attendanceCheckWifi: 0,
  325 + attendanceWifiPairList: [],
  326 + attendanceWifiVerifyPair: 0,
299 }, 327 },
300 rules: { 328 rules: {
301 mdbm: [{ required: true, message: '请输入门店编码', trigger: 'blur' }], 329 mdbm: [{ required: true, message: '请输入门店编码', trigger: 'blur' }],
@@ -383,6 +411,14 @@ export default { @@ -383,6 +411,14 @@ export default {
383 this.dataForm.storeDescription = raw.storeDescription || ''; 411 this.dataForm.storeDescription = raw.storeDescription || '';
384 this.dataForm.businessHours = raw.businessHours || ''; 412 this.dataForm.businessHours = raw.businessHours || '';
385 this.dataForm.trafficTips = raw.trafficTips || ''; 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 } else { 423 } else {
388 // 新建时重置经纬度与围栏 424 // 新建时重置经纬度与围栏
@@ -394,10 +430,38 @@ export default { @@ -394,10 +430,38 @@ export default {
394 this.dataForm.storeTags = []; 430 this.dataForm.storeTags = [];
395 this.dataForm.businessHours = ''; 431 this.dataForm.businessHours = '';
396 this.dataForm.trafficTips = ''; 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 parseJsonArray(value) { 465 parseJsonArray(value) {
402 if (!value) return []; 466 if (!value) return [];
403 if (Array.isArray(value)) return value; 467 if (Array.isArray(value)) return value;
@@ -810,6 +874,16 @@ export default { @@ -810,6 +874,16 @@ export default {
810 dataFormSubmit() { 874 dataFormSubmit() {
811 this.$refs['elForm'].validate((valid) => { 875 this.$refs['elForm'].validate((valid) => {
812 if (!valid) return; 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 const isNew = !this.dataForm.id; 887 const isNew = !this.dataForm.id;
814 // 提交前:fencePolygons 若为数组则序列化为 JSON 字符串,与后端约定一致 888 // 提交前:fencePolygons 若为数组则序列化为 JSON 字符串,与后端约定一致
815 const submitData = { ...this.dataForm }; 889 const submitData = { ...this.dataForm };
@@ -822,6 +896,13 @@ export default { @@ -822,6 +896,13 @@ export default {
822 if (Array.isArray(submitData.storeTags)) { 896 if (Array.isArray(submitData.storeTags)) {
823 submitData.storeTags = JSON.stringify(submitData.storeTags); 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 request({ 906 request({
826 url: isNew ? '/api/Extend/LqMdxx' : `/api/Extend/LqMdxx/${this.dataForm.id}`, 907 url: isNew ? '/api/Extend/LqMdxx' : `/api/Extend/LqMdxx/${this.dataForm.id}`,
827 method: isNew ? 'POST' : 'PUT', 908 method: isNew ? 'POST' : 'PUT',
@@ -844,6 +925,65 @@ export default { @@ -844,6 +925,65 @@ export default {
844 </script> 925 </script>
845 926
846 <style lang="scss" scoped> 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 .fence-status-bar { 987 .fence-status-bar {
848 display: flex; 988 display: flex;
849 align-items: center; 989 align-items: center;
@@ -953,6 +1093,7 @@ export default { @@ -953,6 +1093,7 @@ export default {
953 } 1093 }
954 1094
955 .store-tags-editor { 1095 .store-tags-editor {
  1096 +
956 .input-new-tag, 1097 .input-new-tag,
957 .tag-action-btn { 1098 .tag-action-btn {
958 width: 100%; 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,5 +61,17 @@ namespace NCC.Extend.Entitys.Dto.LqAttendanceRecord
61 /// </summary> 61 /// </summary>
62 [Display(Name = "备注")] 62 [Display(Name = "备注")]
63 public string Remark { get; set; } 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,6 +59,26 @@ namespace NCC.Extend.Entitys.Dto.LqMdxx
59 public string fencePolygons { get; set; } 59 public string fencePolygons { get; set; }
60 60
61 /// <summary> 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 /// </summary> 83 /// </summary>
64 public string xm { get; set; } 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,6 +61,26 @@ namespace NCC.Extend.Entitys.Dto.LqMdxx
61 public string fencePolygons { get; set; } 61 public string fencePolygons { get; set; }
62 62
63 /// <summary> 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 /// </summary> 85 /// </summary>
66 public string xm { get; set; } 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,6 +72,30 @@ namespace NCC.Extend.Entitys.lq_mdxx
72 public string FencePolygons { get; set; } 72 public string FencePolygons { get; set; }
73 73
74 /// <summary> 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 /// </summary> 100 /// </summary>
77 [SugarColumn(ColumnName = "xm")] 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,6 +47,12 @@ namespace NCC.Extend.Entitys.Enum
47 /// 旷工 47 /// 旷工
48 /// </summary> 48 /// </summary>
49 [Description("旷工")] 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,6 +27,7 @@ using NCC.Extend.Entitys.lq_attendance_missing_card_rule;
27 using NCC.Extend.Entitys.lq_attendance_record; 27 using NCC.Extend.Entitys.lq_attendance_record;
28 using NCC.Extend.Entitys.lq_attendance_setting; 28 using NCC.Extend.Entitys.lq_attendance_setting;
29 using NCC.Extend.Entitys.lq_mdxx; 29 using NCC.Extend.Entitys.lq_mdxx;
  30 +using NCC.Extend.Entitys.wform_leave_cancel_apply;
30 using NCC.Extend.Entitys.wform_leaveapply; 31 using NCC.Extend.Entitys.wform_leaveapply;
31 using NCC.Extend.Entitys.flow_task; 32 using NCC.Extend.Entitys.flow_task;
32 using NCC.Extend.Entitys.flow_engine; 33 using NCC.Extend.Entitys.flow_engine;
@@ -96,7 +97,8 @@ namespace NCC.Extend @@ -96,7 +97,8 @@ namespace NCC.Extend
96 /// 获取月度考勤记录矩阵 97 /// 获取月度考勤记录矩阵
97 /// </summary> 98 /// </summary>
98 /// <remarks> 99 /// <remarks>
99 - /// 按统计月份返回员工每日考勤状态;含打卡、补卡及请假流程同步后的「请假/病假」记录。 100 + /// 按统计月份返回员工每日考勤状态;列表包含当前筛选条件下所有在职员工(含当月尚无考勤记录者),
  101 + /// 含打卡、补卡及请假流程同步后的「请假/病假」记录。
100 /// </remarks> 102 /// </remarks>
101 /// <param name="input">查询参数</param> 103 /// <param name="input">查询参数</param>
102 /// <returns>月度考勤记录矩阵</returns> 104 /// <returns>月度考勤记录矩阵</returns>
@@ -227,59 +229,92 @@ namespace NCC.Extend @@ -227,59 +229,92 @@ namespace NCC.Extend
227 229
228 var monthStart = new DateTime(monthDate.Year, monthDate.Month, 1); 230 var monthStart = new DateTime(monthDate.Year, monthDate.Month, 1);
229 var monthEnd = monthStart.AddMonths(1); 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 matchedRecords = await RecalculateRecordStatusesAsync(matchedRecords); 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 .ToList(); 277 .ToList();
278 278
279 return (groupedEmployees, summary); 279 return (groupedEmployees, summary);
280 } 280 }
281 281
282 /// <summary> 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 /// </summary> 319 /// </summary>
285 /// <remarks> 320 /// <remarks>
@@ -327,6 +362,19 @@ namespace NCC.Extend @@ -327,6 +362,19 @@ namespace NCC.Extend
327 362
328 var punchDirection = (AttendancePunchDirectionEnum)input.PunchDirection; 363 var punchDirection = (AttendancePunchDirectionEnum)input.PunchDirection;
329 var punchType = input.PunchType; 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 var fenceValid = EvaluateFenceValid(store, input.Longitude, input.Latitude); 378 var fenceValid = EvaluateFenceValid(store, input.Longitude, input.Latitude);
331 ApplyPunch(record, punchDirection, punchType, punchTime, input.Longitude, input.Latitude, input.Address, input.PhotoUrl, fenceValid); 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,18 +422,33 @@ namespace NCC.Extend
374 var record = await GetAttendanceRecordAsync(input); 422 var record = await GetAttendanceRecordAsync(input);
375 if (record == null) 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 throw NCCException.Oh("未找到对应的打卡记录"); 430 throw NCCException.Oh("未找到对应的打卡记录");
378 } 431 }
379 432
  433 + var statusBeforeDetailRecalc = record.Status;
380 var recalculated = await RecalculateRecordStatusesAsync(new List<LqAttendanceRecordEntity> { record }); 434 var recalculated = await RecalculateRecordStatusesAsync(new List<LqAttendanceRecordEntity> { record });
381 record = recalculated.FirstOrDefault() ?? record; 435 record = recalculated.FirstOrDefault() ?? record;
  436 + // 重算后状态变化时落库(含:备注已销假但库中仍为请假、待考勤已到期应转为缺卡等)
  437 + if (record.Status != statusBeforeDetailRecalc)
  438 + {
  439 + await SaveAttendanceRecordAsync(record);
  440 + }
  441 +
382 var group = await GetAttendanceGroupAsync(record.AttendanceGroupId, false); 442 var group = await GetAttendanceGroupAsync(record.AttendanceGroupId, false);
383 443
384 var relatedWorkflows = ParseRelatedWorkflows(record.RelatedWorkflowsJson); 444 var relatedWorkflows = ParseRelatedWorkflows(record.RelatedWorkflowsJson);
385 445
  446 + // 销假后考勤状态可能会从“请假/病假”恢复到“正常/缺卡”等,
  447 + // 但 record.Remark 仍可能保留“请假审批通过:...;销假:...”信息。
  448 + // 因此解析 leaveType/billNo 时不再强依赖 isLeaveStatus。
386 var isLeaveStatus = record.Status == (int)AttendanceRecordStatusEnum.请假 449 var isLeaveStatus = record.Status == (int)AttendanceRecordStatusEnum.请假
387 || record.Status == (int)AttendanceRecordStatusEnum.病假; 450 || record.Status == (int)AttendanceRecordStatusEnum.病假;
388 - if (!relatedWorkflows.Any(x => x.type == "请假") && isLeaveStatus 451 + if (!relatedWorkflows.Any(x => x.type == "请假")
389 && TryParseLeaveApplyFromSyncRemark(record.Remark, out var parsedLeaveType, out var parsedBillNo) 452 && TryParseLeaveApplyFromSyncRemark(record.Remark, out var parsedLeaveType, out var parsedBillNo)
390 && !string.IsNullOrWhiteSpace(parsedBillNo)) 453 && !string.IsNullOrWhiteSpace(parsedBillNo))
391 { 454 {
@@ -406,6 +469,31 @@ namespace NCC.Extend @@ -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 if (!string.IsNullOrWhiteSpace(record.SupplementWorkflowId) && !relatedWorkflows.Any(x => x.type == "补卡")) 497 if (!string.IsNullOrWhiteSpace(record.SupplementWorkflowId) && !relatedWorkflows.Any(x => x.type == "补卡"))
410 { 498 {
411 relatedWorkflows.Add(new RelatedWorkflowItem 499 relatedWorkflows.Add(new RelatedWorkflowItem
@@ -1060,7 +1148,9 @@ namespace NCC.Extend @@ -1060,7 +1148,9 @@ namespace NCC.Extend
1060 } 1148 }
1061 else if (!record.PunchInTime.HasValue && !record.PunchOutTime.HasValue) 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 else 1155 else
1066 { 1156 {
@@ -1472,11 +1562,11 @@ namespace NCC.Extend @@ -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 return new 1567 return new
1478 { 1568 {
1479 - employeeCount = matchedRecords.Select(x => x.UserId).Distinct().Count(), 1569 + employeeCount = displayedEmployeeCount,
1480 normalCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.正常), 1570 normalCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.正常),
1481 lateCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.迟到), 1571 lateCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.迟到),
1482 leaveAndMissingCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.请假 1572 leaveAndMissingCount = matchedRecords.Count(x => x.Status == (int)AttendanceRecordStatusEnum.请假
@@ -1667,9 +1757,28 @@ namespace NCC.Extend @@ -1667,9 +1757,28 @@ namespace NCC.Extend
1667 1757
1668 private async Task<LqAttendanceRecordEntity> GetAttendanceRecordByUserDateAsync(string userId, DateTime attendanceDate) 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 /// <param name="bypassSnapshotPairSkip">为 true 时不过滤「规则快照与全量快照均已存在」的记录(用于历史缺卡批量修正)</param> 1784 /// <param name="bypassSnapshotPairSkip">为 true 时不过滤「规则快照与全量快照均已存在」的记录(用于历史缺卡批量修正)</param>
@@ -1723,31 +1832,60 @@ namespace NCC.Extend @@ -1723,31 +1832,60 @@ namespace NCC.Extend
1723 1832
1724 foreach (var record in records) 1833 foreach (var record in records)
1725 { 1834 {
1726 - // 流程同步写入的请假/病假(含应休「休假」、年假等):勿按打卡重算覆盖为缺卡/正常,否则详情无法销假、应休/年假额度无法按考勤折算还原 1835 + // 流程同步写入的请假/病假(含应休「休假」、年假等):勿按打卡重算覆盖为缺卡/正常,否则详情无法销假、应休/年假额度无法按考勤折算还原。
  1836 + // 备注已含「销假」时不再跳过:否则会出现库中仍为请假/病假但备注已销假、详情与列表长期不一致。
1727 if (record.IsManual == 1 1837 if (record.IsManual == 1
1728 && (record.Status == (int)AttendanceRecordStatusEnum.请假 || record.Status == (int)AttendanceRecordStatusEnum.病假) 1838 && (record.Status == (int)AttendanceRecordStatusEnum.请假 || record.Status == (int)AttendanceRecordStatusEnum.病假)
1729 && !string.IsNullOrWhiteSpace(record.Remark) 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 continue; 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 if (!bypassSnapshotPairSkip 1862 if (!bypassSnapshotPairSkip
1736 && !string.IsNullOrWhiteSpace(record.RuleSnapshotJson) 1863 && !string.IsNullOrWhiteSpace(record.RuleSnapshotJson)
1737 - && !string.IsNullOrWhiteSpace(record.AllRuleSnapshotJson)) 1864 + && !string.IsNullOrWhiteSpace(record.AllRuleSnapshotJson)
  1865 + && !leaveSyncedButCancelledInRemark
  1866 + && !pendingAttendanceExpired
  1867 + && !futureDayMissingShouldBePending)
1738 { 1868 {
1739 continue; 1869 continue;
1740 } 1870 }
1741 1871
1742 - var attendanceDate = record.AttendanceDate.Date;  
1743 var group = ResolveRecordAttendanceGroup(record, userDict, groupDict); 1872 var group = ResolveRecordAttendanceGroup(record, userDict, groupDict);
1744 var isHoliday = holidayDates.Contains(attendanceDate); 1873 var isHoliday = holidayDates.Contains(attendanceDate);
1745 var isExempt = IsExemptForDate(exemptUsers, record.UserId, attendanceDate); 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 var statusContext = ResolveAttendanceStatus( 1884 var statusContext = ResolveAttendanceStatus(
1747 attendanceDate, 1885 attendanceDate,
1748 group, 1886 group,
1749 lateRules, 1887 lateRules,
1750 - record.Status, 1888 + statusForResolution,
1751 record.PunchInTime, 1889 record.PunchInTime,
1752 record.PunchOutTime, 1890 record.PunchOutTime,
1753 null, 1891 null,
@@ -1807,9 +1945,19 @@ namespace NCC.Extend @@ -1807,9 +1945,19 @@ namespace NCC.Extend
1807 { 1945 {
1808 if (!string.IsNullOrWhiteSpace(input.Id)) 1946 if (!string.IsNullOrWhiteSpace(input.Id))
1809 { 1947 {
1810 - return await _db.Queryable<LqAttendanceRecordEntity>() 1948 + var effectiveList = await _db.Queryable<LqAttendanceRecordEntity>()
1811 .Where(x => x.Id == input.Id && x.IsEffective == StatusEnum.有效.GetHashCode()) 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 if (string.IsNullOrWhiteSpace(input.UserId) || string.IsNullOrWhiteSpace(input.AttendanceDate)) 1963 if (string.IsNullOrWhiteSpace(input.UserId) || string.IsNullOrWhiteSpace(input.AttendanceDate))
@@ -1818,7 +1966,11 @@ namespace NCC.Extend @@ -1818,7 +1966,11 @@ namespace NCC.Extend
1818 } 1966 }
1819 1967
1820 var attendanceDate = ParseAttendanceDate(input.AttendanceDate); 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 private static LqAttendanceRecordEntity CreateAttendanceRecord(UserEntity user, LqMdxxEntity store, LqAttendanceGroupEntity group, DateTime attendanceDate, string operatorUserId) 1976 private static LqAttendanceRecordEntity CreateAttendanceRecord(UserEntity user, LqMdxxEntity store, LqAttendanceGroupEntity group, DateTime attendanceDate, string operatorUserId)
@@ -1834,7 +1986,9 @@ namespace NCC.Extend @@ -1834,7 +1986,9 @@ namespace NCC.Extend
1834 AttendanceGroupId = group?.Id, 1986 AttendanceGroupId = group?.Id,
1835 AttendanceGroupName = group?.GroupName, 1987 AttendanceGroupName = group?.GroupName,
1836 AttendanceDate = attendanceDate.Date, 1988 AttendanceDate = attendanceDate.Date,
1837 - Status = (int)AttendanceRecordStatusEnum.缺卡, 1989 + Status = attendanceDate.Date > DateTime.Today.Date
  1990 + ? (int)AttendanceRecordStatusEnum.待考勤
  1991 + : (int)AttendanceRecordStatusEnum.缺卡,
1838 LateMinutes = 0, 1992 LateMinutes = 0,
1839 EarlyLeaveMinutes = 0, 1993 EarlyLeaveMinutes = 0,
1840 IsManual = 0, 1994 IsManual = 0,
@@ -1956,9 +2110,12 @@ namespace NCC.Extend @@ -1956,9 +2110,12 @@ namespace NCC.Extend
1956 // 取消流程补卡、销假回退等场景会清空打卡时间;双无打卡不应判为「正常」,与销假后逻辑一致(见 ExecuteCancelLeaveCoreAsync) 2110 // 取消流程补卡、销假回退等场景会清空打卡时间;双无打卡不应判为「正常」,与销假后逻辑一致(见 ExecuteCancelLeaveCoreAsync)
1957 if (!punchInTime.HasValue && !punchOutTime.HasValue) 2111 if (!punchInTime.HasValue && !punchOutTime.HasValue)
1958 { 2112 {
  2113 + var noPunchStatus = attendanceDate.Date > DateTime.Today.Date
  2114 + ? (int)AttendanceRecordStatusEnum.待考勤
  2115 + : (int)AttendanceRecordStatusEnum.缺卡;
1959 return new AttendanceStatusContext 2116 return new AttendanceStatusContext
1960 { 2117 {
1961 - Status = (int)AttendanceRecordStatusEnum.缺卡, 2118 + Status = noPunchStatus,
1962 LateMinutes = 0, 2119 LateMinutes = 0,
1963 EarlyLeaveMinutes = 0 2120 EarlyLeaveMinutes = 0
1964 }; 2121 };
@@ -2005,6 +2162,154 @@ namespace NCC.Extend @@ -2005,6 +2162,154 @@ namespace NCC.Extend
2005 return lateRules.Any(rule => minutes >= rule.MinMinutes && (!rule.MaxMinutes.HasValue || minutes < rule.MaxMinutes.Value)); 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 private static int? EvaluateFenceValid(LqMdxxEntity store, decimal? longitude, decimal? latitude) 2313 private static int? EvaluateFenceValid(LqMdxxEntity store, decimal? longitude, decimal? latitude)
2009 { 2314 {
2010 if (store == null || !longitude.HasValue || !latitude.HasValue || string.IsNullOrWhiteSpace(store.FencePolygons)) 2315 if (store == null || !longitude.HasValue || !latitude.HasValue || string.IsNullOrWhiteSpace(store.FencePolygons))
@@ -2246,12 +2551,77 @@ namespace NCC.Extend @@ -2246,12 +2551,77 @@ namespace NCC.Extend
2246 w.taskNodeId 2551 w.taskNodeId
2247 }).ToList(), 2552 }).ToList(),
2248 canCancelWorkflowSupplement, 2553 canCancelWorkflowSupplement,
  2554 + hasAttendanceRecord = true,
2249 ruleSnapshotJson = record.RuleSnapshotJson, 2555 ruleSnapshotJson = record.RuleSnapshotJson,
2250 allRuleSnapshotJson = record.AllRuleSnapshotJson 2556 allRuleSnapshotJson = record.AllRuleSnapshotJson
2251 }; 2557 };
2252 } 2558 }
2253 2559
2254 /// <summary> 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 /// 是否允许「取消流程补卡」:能解析到补卡申请单,且申请目标中存在与本考勤日对应的勾选项(含 PascalCase、日期格式差异、单日申请兜底)。 2625 /// 是否允许「取消流程补卡」:能解析到补卡申请单,且申请目标中存在与本考勤日对应的勾选项(含 PascalCase、日期格式差异、单日申请兜底)。
2256 /// </summary> 2626 /// </summary>
2257 private async Task<bool> ComputeCanCancelWorkflowSupplementAsync(LqAttendanceRecordEntity record) 2627 private async Task<bool> ComputeCanCancelWorkflowSupplementAsync(LqAttendanceRecordEntity record)
@@ -2770,6 +3140,8 @@ namespace NCC.Extend @@ -2770,6 +3140,8 @@ namespace NCC.Extend
2770 return null; 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 return new 3145 return new
2774 { 3146 {
2775 storeId = entity.Id, 3147 storeId = entity.Id,
@@ -2781,7 +3153,13 @@ namespace NCC.Extend @@ -2781,7 +3153,13 @@ namespace NCC.Extend
2781 fencePolygons = entity.FencePolygons, 3153 fencePolygons = entity.FencePolygons,
2782 hasFence = !string.IsNullOrWhiteSpace(entity.FencePolygons), 3154 hasFence = !string.IsNullOrWhiteSpace(entity.FencePolygons),
2783 businessHours = entity.BusinessHours, 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,6 +3410,13 @@ namespace NCC.Extend
3032 private static string GetAttendanceRecordStatusDisplayText(LqAttendanceRecordEntity record) 3410 private static string GetAttendanceRecordStatusDisplayText(LqAttendanceRecordEntity record)
3033 { 3411 {
3034 var (_, statusText, _) = MapStatus(record.Status); 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 if (record.Status == (int)AttendanceRecordStatusEnum.请假) 3420 if (record.Status == (int)AttendanceRecordStatusEnum.请假)
3036 { 3421 {
3037 var label = TryGetLeaveTypeLabelFromSyncRemark(record.Remark); 3422 var label = TryGetLeaveTypeLabelFromSyncRemark(record.Remark);
@@ -3157,6 +3542,7 @@ namespace NCC.Extend @@ -3157,6 +3542,7 @@ namespace NCC.Extend
3157 } 3542 }
3158 3543
3159 if (statusKey == "sick") return "病假"; 3544 if (statusKey == "sick") return "病假";
  3545 + if (statusKey == "pending") return "暂无打卡";
3160 if (statusKey == "missing") return "缺卡"; 3546 if (statusKey == "missing") return "缺卡";
3161 if (statusKey == "absenteeism") return "旷工"; 3547 if (statusKey == "absenteeism") return "旷工";
3162 return "--"; 3548 return "--";
@@ -3181,6 +3567,8 @@ namespace NCC.Extend @@ -3181,6 +3567,8 @@ namespace NCC.Extend
3181 return ("sick", "病假", "danger"); 3567 return ("sick", "病假", "danger");
3182 case AttendanceRecordStatusEnum.缺卡: 3568 case AttendanceRecordStatusEnum.缺卡:
3183 return ("missing", "缺卡", "danger"); 3569 return ("missing", "缺卡", "danger");
  3570 + case AttendanceRecordStatusEnum.待考勤:
  3571 + return ("pending", "暂无打卡", "info");
3184 case AttendanceRecordStatusEnum.旷工: 3572 case AttendanceRecordStatusEnum.旷工:
3185 return ("absenteeism", "旷工", "danger"); 3573 return ("absenteeism", "旷工", "danger");
3186 default: 3574 default:
@@ -3284,7 +3672,7 @@ namespace NCC.Extend @@ -3284,7 +3672,7 @@ namespace NCC.Extend
3284 } 3672 }
3285 3673
3286 /// <summary> 3674 /// <summary>
3287 - /// 为「请假」「补卡」等关联项补全打开流程详情所需字段(FLOW_TASK / FLOW_ENGINE) 3675 + /// 为「请假」「补卡」「销假」等关联项补全打开流程详情所需字段(FLOW_TASK / FLOW_ENGINE)
3288 /// </summary> 3676 /// </summary>
3289 private async Task FillRelatedWorkflowFlowOpenAsync(List<RelatedWorkflowItem> list) 3677 private async Task FillRelatedWorkflowFlowOpenAsync(List<RelatedWorkflowItem> list)
3290 { 3678 {
@@ -3294,7 +3682,7 @@ namespace NCC.Extend @@ -3294,7 +3682,7 @@ namespace NCC.Extend
3294 } 3682 }
3295 3683
3296 foreach (var w in list.Where(x => 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 if (!string.IsNullOrWhiteSpace(w.flowId) 3687 if (!string.IsNullOrWhiteSpace(w.flowId)
3300 && !string.IsNullOrWhiteSpace(w.flowCode) 3688 && !string.IsNullOrWhiteSpace(w.flowCode)
@@ -3362,6 +3750,21 @@ namespace NCC.Extend @@ -3362,6 +3750,21 @@ namespace NCC.Extend
3362 return JsonConvert.SerializeObject(list); 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 private sealed class PunchApplyTargetLine 3768 private sealed class PunchApplyTargetLine
3366 { 3769 {
3367 public string date { get; set; } 3770 public string date { get; set; }
绿纤uni-app/pages/attendance-punch/attendance-punch.vue
@@ -18,29 +18,19 @@ @@ -18,29 +18,19 @@
18 <!-- 打卡方式:与小程序白卡片风格一致 --> 18 <!-- 打卡方式:与小程序白卡片风格一致 -->
19 <view class="att-card att-seg-card"> 19 <view class="att-card att-seg-card">
20 <view class="att-seg"> 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 </view> 25 </view>
32 </view> 26 </view>
33 27
34 <!-- 单一大按钮:按顺序上班 → 下班 → 已完成 --> 28 <!-- 单一大按钮:按顺序上班 → 下班 → 已完成 -->
35 <view class="att-card att-punch-card"> 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 <template v-if="allPunchDone"> 34 <template v-if="allPunchDone">
45 <u-icon name="checkmark-circle-fill" color="#ffffff" size="52"></u-icon> 35 <u-icon name="checkmark-circle-fill" color="#ffffff" size="52"></u-icon>
46 <text class="att-big-done-text">今日打卡已完成</text> 36 <text class="att-big-done-text">今日打卡已完成</text>
@@ -61,17 +51,28 @@ @@ -61,17 +51,28 @@
61 <text class="att-loc-text">{{ locationText }}</text> 51 <text class="att-loc-text">{{ locationText }}</text>
62 <text class="att-loc-tip">{{ fenceStatusText }}</text> 52 <text class="att-loc-tip">{{ fenceStatusText }}</text>
63 <view class="att-loc-actions"> 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 <text class="att-loc-action" @click.stop="refreshLocation">重新定位</text> 56 <text class="att-loc-action" @click.stop="refreshLocation">重新定位</text>
70 </view> 57 </view>
71 </view> 58 </view>
72 <u-icon name="arrow-right" color="#c8c9cc" size="18"></u-icon> 59 <u-icon name="arrow-right" color="#c8c9cc" size="18"></u-icon>
73 </view> 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 <view class="att-card"> 76 <view class="att-card">
76 <view class="att-card-head"> 77 <view class="att-card-head">
77 <text class="att-card-title">今日记录</text> 78 <text class="att-card-title">今日记录</text>
@@ -84,7 +85,9 @@ @@ -84,7 +85,9 @@
84 <view class="att-tl-body"> 85 <view class="att-tl-body">
85 <view class="att-tl-row"> 86 <view class="att-tl-row">
86 <text class="att-tl-name">上班</text> 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 </view> 91 </view>
89 </view> 92 </view>
90 </view> 93 </view>
@@ -93,7 +96,9 @@ @@ -93,7 +96,9 @@
93 <view class="att-tl-body"> 96 <view class="att-tl-body">
94 <view class="att-tl-row"> 97 <view class="att-tl-row">
95 <text class="att-tl-name">下班</text> 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 </view> 102 </view>
98 </view> 103 </view>
99 </view> 104 </view>
@@ -107,25 +112,13 @@ @@ -107,25 +112,13 @@
107 <view class="att-card att-more-card"> 112 <view class="att-card att-more-card">
108 <view class="att-more-title">打卡照片(选填)</view> 113 <view class="att-more-title">打卡照片(选填)</view>
109 <view class="att-upload-wrap"> 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 </view> 117 </view>
121 <template v-if="punchType === 2"> 118 <template v-if="punchType === 2">
122 <view class="att-more-title att-more-title--second">外勤说明(选填)</view> 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 </template> 122 </template>
130 </view> 123 </view>
131 124
@@ -140,728 +133,946 @@ @@ -140,728 +133,946 @@
140 </template> 133 </template>
141 134
142 <script> 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 if (this.longitude == null || this.latitude == null) return '点击更新定位后校验是否在门店范围内' 297 if (this.longitude == null || this.latitude == null) return '点击更新定位后校验是否在门店范围内'
210 if (this.isInFence === true) return '当前位于门店打卡范围内,可正常上下班打卡' 298 if (this.isInFence === true) return '当前位于门店打卡范围内,可正常上下班打卡'
211 if (this.isInFence === false) return '当前不在门店打卡范围内,正常打卡不可用' 299 if (this.isInFence === false) return '当前不在门店打卡范围内,正常打卡不可用'
212 return '定位完成后自动校验门店围栏范围' 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 this.detail = null 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 this.punchConfig = null 497 this.punchConfig = null
346 this.storeFence = null 498 this.storeFence = null
347 this.attendanceGroup = null 499 this.attendanceGroup = null
348 this.fenceEnabled = false 500 this.fenceEnabled = false
349 this.isInFence = null 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 try { 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 if (this.longitude == null || this.latitude == null) { 671 if (this.longitude == null || this.latitude == null) {
466 await this.refreshLocation() 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 </script> 714 </script>
504 715
505 <style scoped lang="scss"> 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 </style> 1078 </style>
绿纤uni-app/pagesA/my-application-list/my-application-list.vue
@@ -5,6 +5,18 @@ @@ -5,6 +5,18 @@
5 <view class="header-sub">查看我发起的流程申请,支持进入详情查看表单与审批进度</view> 5 <view class="header-sub">查看我发起的流程申请,支持进入详情查看表单与审批进度</view>
6 </view> 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 <view v-if="loading && !list.length" class="state-card"> 20 <view v-if="loading && !list.length" class="state-card">
9 <text class="state-text">加载中...</text> 21 <text class="state-text">加载中...</text>
10 </view> 22 </view>
@@ -63,6 +75,13 @@ export default { @@ -63,6 +75,13 @@ export default {
63 return { 75 return {
64 loading: false, 76 loading: false,
65 list: [], 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 query: { 85 query: {
67 currentPage: 1, 86 currentPage: 1,
68 pageSize: 20, 87 pageSize: 20,
@@ -73,8 +92,14 @@ export default { @@ -73,8 +92,14 @@ export default {
73 } 92 }
74 }, 93 },
75 computed: { 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 displayList() { 101 displayList() {
77 - return (this.list || []).map(item => ({ 102 + return (this.filteredList || []).map(item => ({
78 ...item, 103 ...item,
79 statusClassName: this.statusClass(item.status), 104 statusClassName: this.statusClass(item.status),
80 statusText: this.getStatusText(item.status), 105 statusText: this.getStatusText(item.status),
@@ -115,6 +140,11 @@ export default { @@ -115,6 +140,11 @@ export default {
115 uni.stopPullDownRefresh() 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 refresh() { 148 refresh() {
119 this.query.currentPage = 1 149 this.query.currentPage = 1
120 this.total = 0 150 this.total = 0
@@ -151,6 +181,15 @@ export default { @@ -151,6 +181,15 @@ export default {
151 } 181 }
152 return map[Number(status)] || 'status-default' 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 getUrgentText(val) { 193 getUrgentText(val) {
155 const map = { 194 const map = {
156 1: '普通', 195 1: '普通',
@@ -192,6 +231,28 @@ export default { @@ -192,6 +231,28 @@ export default {
192 box-sizing: border-box; 231 box-sizing: border-box;
193 background: linear-gradient(180deg, #e8f5e9 0%, #f7fff8 100%); 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 .header-card, 256 .header-card,
196 .state-card, 257 .state-card,
197 .apply-card { 258 .apply-card {
绿纤uni-app/unpackage/dist/dev/mp-weixin/pages/attendance-punch/attendance-punch.js
@@ -134,6 +134,15 @@ var render = function () { @@ -134,6 +134,15 @@ var render = function () {
134 var _vm = this 134 var _vm = this
135 var _h = _vm.$createElement 135 var _h = _vm.$createElement
136 var _c = _vm._self._c || _h 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 var recyclableRender = false 147 var recyclableRender = false
139 var staticRenderFns = [] 148 var staticRenderFns = []
@@ -312,13 +321,6 @@ var _attendanceFence = __webpack_require__(/*! @/service/attendance-fence.js */ @@ -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 var _default = { 324 var _default = {
323 data: function data() { 325 data: function data() {
324 return { 326 return {
@@ -341,7 +343,11 @@ var _default = { @@ -341,7 +343,11 @@ var _default = {
341 photoUrl: '', 343 photoUrl: '',
342 photoFileList: [], 344 photoFileList: [],
343 clockTime: '--:--:--', 345 clockTime: '--:--:--',
344 - clockTimer: null 346 + clockTimer: null,
  347 + wifiSSID: '',
  348 + wifiBSSID: '',
  349 + wifiLoading: false,
  350 + wifiError: ''
345 }; 351 };
346 }, 352 },
347 computed: { 353 computed: {
@@ -385,14 +391,95 @@ var _default = { @@ -385,14 +391,95 @@ var _default = {
385 } 391 }
386 return '点击获取定位,用于考勤校验'; 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 fenceStatusText: function fenceStatusText() { 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 punchInDone: function punchInDone() { 484 punchInDone: function punchInDone() {
398 return !!(this.detail && this.detail.punchIn && this.detail.punchIn.time); 485 return !!(this.detail && this.detail.punchIn && this.detail.punchIn.time);
@@ -425,11 +512,38 @@ var _default = { @@ -425,11 +512,38 @@ var _default = {
425 if (this.loading || this.submitting || this.locating) return false; 512 if (this.loading || this.submitting || this.locating) return false;
426 if (!this.userInfo.userId) return false; 513 if (!this.userInfo.userId) return false;
427 if (this.nextPunchDirection == null) return false; 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 return true; 516 return true;
430 }, 517 },
431 canViewFenceMap: function canViewFenceMap() { 518 canViewFenceMap: function canViewFenceMap() {
432 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); 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 onLoad: function onLoad() { 549 onLoad: function onLoad() {
@@ -453,28 +567,39 @@ var _default = { @@ -453,28 +567,39 @@ var _default = {
453 }); 567 });
454 }, 568 },
455 methods: { 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 /** 切回「正常」时只清空外勤说明;照片正常/外勤共用,保留 */setPunchType: function setPunchType(type) { 578 /** 切回「正常」时只清空外勤说明;照片正常/外勤共用,保留 */setPunchType: function setPunchType(type) {
457 this.punchType = type; 579 this.punchType = type;
458 - if (type === 1) { 580 + if (Number(type) === 1) {
459 this.remark = ''; 581 this.remark = '';
  582 + this.refreshWifiInfo();
460 } 583 }
461 }, 584 },
462 initializePage: function initializePage() { 585 initializePage: function initializePage() {
463 - var _this = this; 586 + var _this2 = this;
464 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee() { 587 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee() {
  588 + var wifiTask;
465 return _regenerator.default.wrap(function _callee$(_context) { 589 return _regenerator.default.wrap(function _callee$(_context) {
466 while (1) { 590 while (1) {
467 switch (_context.prev = _context.next) { 591 switch (_context.prev = _context.next) {
468 case 0: 592 case 0:
469 _context.next = 2; 593 _context.next = 2;
470 - return _this.loadPunchConfig(); 594 + return _this2.loadPunchConfig();
471 case 2: 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 case "end": 603 case "end":
479 return _context.stop(); 604 return _context.stop();
480 } 605 }
@@ -484,9 +609,9 @@ var _default = { @@ -484,9 +609,9 @@ var _default = {
484 }, 609 },
485 onMainPunch: function onMainPunch() { 610 onMainPunch: function onMainPunch() {
486 if (!this.nextPunchDirection) return; 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 uni.showToast({ 613 uni.showToast({
489 - title: '当前不在门店打卡范围内,无法正常打卡', 614 + title: '未满足门店要求(需在范围内或指定 Wi-Fi/BSSID),可改用外勤',
490 icon: 'none' 615 icon: 'none'
491 }); 616 });
492 return; 617 return;
@@ -518,42 +643,42 @@ var _default = { @@ -518,42 +643,42 @@ var _default = {
518 return "".concat(y, "-").concat(m, "-").concat(day); 643 return "".concat(y, "-").concat(m, "-").concat(day);
519 }, 644 },
520 loadDetail: function loadDetail() { 645 loadDetail: function loadDetail() {
521 - var _this2 = this; 646 + var _this3 = this;
522 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee2() { 647 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee2() {
523 var res; 648 var res;
524 return _regenerator.default.wrap(function _callee2$(_context2) { 649 return _regenerator.default.wrap(function _callee2$(_context2) {
525 while (1) { 650 while (1) {
526 switch (_context2.prev = _context2.next) { 651 switch (_context2.prev = _context2.next) {
527 case 0: 652 case 0:
528 - if (_this2.userInfo.userId) { 653 + if (_this3.userInfo.userId) {
529 _context2.next = 2; 654 _context2.next = 2;
530 break; 655 break;
531 } 656 }
532 return _context2.abrupt("return"); 657 return _context2.abrupt("return");
533 case 2: 658 case 2:
534 - _this2.loading = true; 659 + _this3.loading = true;
535 _context2.prev = 3; 660 _context2.prev = 3;
536 _context2.next = 6; 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 case 6: 666 case 6:
542 res = _context2.sent; 667 res = _context2.sent;
543 if (res && Number(res.code) === 200 && res.data) { 668 if (res && Number(res.code) === 200 && res.data) {
544 - _this2.detail = res.data; 669 + _this3.detail = res.data;
545 } else { 670 } else {
546 - _this2.detail = null; 671 + _this3.detail = null;
547 } 672 }
548 _context2.next = 13; 673 _context2.next = 13;
549 break; 674 break;
550 case 10: 675 case 10:
551 _context2.prev = 10; 676 _context2.prev = 10;
552 _context2.t0 = _context2["catch"](3); 677 _context2.t0 = _context2["catch"](3);
553 - _this2.detail = null; 678 + _this3.detail = null;
554 case 13: 679 case 13:
555 _context2.prev = 13; 680 _context2.prev = 13;
556 - _this2.loading = false; 681 + _this3.loading = false;
557 return _context2.finish(13); 682 return _context2.finish(13);
558 case 16: 683 case 16:
559 case "end": 684 case "end":
@@ -564,52 +689,52 @@ var _default = { @@ -564,52 +689,52 @@ var _default = {
564 }))(); 689 }))();
565 }, 690 },
566 loadPunchConfig: function loadPunchConfig() { 691 loadPunchConfig: function loadPunchConfig() {
567 - var _this3 = this; 692 + var _this4 = this;
568 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee3() { 693 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee3() {
569 var res; 694 var res;
570 return _regenerator.default.wrap(function _callee3$(_context3) { 695 return _regenerator.default.wrap(function _callee3$(_context3) {
571 while (1) { 696 while (1) {
572 switch (_context3.prev = _context3.next) { 697 switch (_context3.prev = _context3.next) {
573 case 0: 698 case 0:
574 - if (_this3.userInfo.userId) { 699 + if (_this4.userInfo.userId) {
575 _context3.next = 7; 700 _context3.next = 7;
576 break; 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 return _context3.abrupt("return"); 708 return _context3.abrupt("return");
584 case 7: 709 case 7:
585 _context3.prev = 7; 710 _context3.prev = 7;
586 _context3.next = 10; 711 _context3.next = 10;
587 - return _this3.API.getCurrentAttendancePunchConfig({}); 712 + return _this4.API.getCurrentAttendancePunchConfig({});
588 case 10: 713 case 10:
589 res = _context3.sent; 714 res = _context3.sent;
590 if (res && Number(res.code) === 200 && res.data) { 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 } else { 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 _context3.next = 21; 728 _context3.next = 21;
604 break; 729 break;
605 case 14: 730 case 14:
606 _context3.prev = 14; 731 _context3.prev = 14;
607 _context3.t0 = _context3["catch"](7); 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 case 21: 738 case 21:
614 case "end": 739 case "end":
615 return _context3.stop(); 740 return _context3.stop();
@@ -618,24 +743,84 @@ var _default = { @@ -618,24 +743,84 @@ var _default = {
618 }, _callee3, null, [[7, 14]]); 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 refreshLocation: function refreshLocation() { 806 refreshLocation: function refreshLocation() {
622 - var _this4 = this; 807 + var _this6 = this;
623 this.locating = true; 808 this.locating = true;
624 return new Promise(function (resolve) { 809 return new Promise(function (resolve) {
625 uni.getLocation({ 810 uni.getLocation({
626 type: 'gcj02', 811 type: 'gcj02',
627 isHighAccuracy: true, 812 isHighAccuracy: true,
628 success: function success(res) { 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 resolve(); 819 resolve();
635 }, 820 },
636 fail: function fail() { 821 fail: function fail() {
637 - _this4.locating = false;  
638 - _this4.isInFence = null; 822 + _this6.locating = false;
  823 + _this6.isInFence = null;
639 uni.showToast({ 824 uni.showToast({
640 title: '定位失败,请检查系统定位权限', 825 title: '定位失败,请检查系统定位权限',
641 icon: 'none' 826 icon: 'none'
@@ -663,7 +848,7 @@ var _default = { @@ -663,7 +848,7 @@ var _default = {
663 }); 848 });
664 }, 849 },
665 evaluateFenceRange: function evaluateFenceRange() { 850 evaluateFenceRange: function evaluateFenceRange() {
666 - var _this5 = this; 851 + var _this7 = this;
667 if (!this.fenceEnabled) { 852 if (!this.fenceEnabled) {
668 this.isInFence = true; 853 this.isInFence = true;
669 return; 854 return;
@@ -678,14 +863,14 @@ var _default = { @@ -678,14 +863,14 @@ var _default = {
678 return; 863 return;
679 } 864 }
680 this.isInFence = polygons.some(function (polygon) { 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 isPointInPolygon: function isPointInPolygon(lng, lat, polygon) { 869 isPointInPolygon: function isPointInPolygon(lng, lat, polygon) {
685 return (0, _attendanceFence.isPointInPolygon)(lng, lat, polygon); 870 return (0, _attendanceFence.isPointInPolygon)(lng, lat, polygon);
686 }, 871 },
687 afterReadPhoto: function afterReadPhoto(event) { 872 afterReadPhoto: function afterReadPhoto(event) {
688 - var _this6 = this; 873 + var _this8 = this;
689 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee4() { 874 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee4() {
690 var lists, file, fileObj, result; 875 var lists, file, fileObj, result;
691 return _regenerator.default.wrap(function _callee4$(_context4) { 876 return _regenerator.default.wrap(function _callee4$(_context4) {
@@ -705,13 +890,13 @@ var _default = { @@ -705,13 +890,13 @@ var _default = {
705 title: '上传中' 890 title: '上传中'
706 }); 891 });
707 _context4.next = 8; 892 _context4.next = 8;
708 - return _this6.API.uploadFile(fileObj); 893 + return _this8.API.uploadFile(fileObj);
709 case 8: 894 case 8:
710 result = _context4.sent; 895 result = _context4.sent;
711 uni.hideLoading(); 896 uni.hideLoading();
712 if (result && result.code === 200 && result.data && result.data.url) { 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 url: result.data.url, 900 url: result.data.url,
716 status: 'success' 901 status: 'success'
717 }]; 902 }];
@@ -744,14 +929,14 @@ var _default = { @@ -744,14 +929,14 @@ var _default = {
744 this.photoFileList = []; 929 this.photoFileList = [];
745 }, 930 },
746 doPunch: function doPunch(direction) { 931 doPunch: function doPunch(direction) {
747 - var _this7 = this; 932 + var _this9 = this;
748 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee5() { 933 return (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee5() {
749 - var isFieldWork, payload, res; 934 + var isNormal, needGps, isFieldWork, payload, res;
750 return _regenerator.default.wrap(function _callee5$(_context5) { 935 return _regenerator.default.wrap(function _callee5$(_context5) {
751 while (1) { 936 while (1) {
752 switch (_context5.prev = _context5.next) { 937 switch (_context5.prev = _context5.next) {
753 case 0: 938 case 0:
754 - if (_this7.userInfo.userId) { 939 + if (_this9.userInfo.userId) {
755 _context5.next = 3; 940 _context5.next = 3;
756 break; 941 break;
757 } 942 }
@@ -761,81 +946,99 @@ var _default = { @@ -761,81 +946,99 @@ var _default = {
761 }); 946 });
762 return _context5.abrupt("return"); 947 return _context5.abrupt("return");
763 case 3: 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 break; 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 _context5.next = 9; 956 _context5.next = 9;
773 break; 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 uni.showToast({ 966 uni.showToast({
776 title: '请先完成定位', 967 title: '请先完成定位',
777 icon: 'none' 968 icon: 'none'
778 }); 969 });
779 return _context5.abrupt("return"); 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 payload = { 983 payload = {
783 - userId: _this7.userInfo.userId, 984 + userId: _this9.userInfo.userId,
784 punchDirection: direction, 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 res = _context5.sent; 1000 res = _context5.sent;
798 if (!(res && Number(res.code) === 200)) { 1001 if (!(res && Number(res.code) === 200)) {
799 - _context5.next = 25; 1002 + _context5.next = 33;
800 break; 1003 break;
801 } 1004 }
802 uni.showToast({ 1005 uni.showToast({
803 title: '打卡成功', 1006 title: '打卡成功',
804 icon: 'success' 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 break; 1016 break;
814 - case 25: 1017 + case 33:
815 uni.showToast({ 1018 uni.showToast({
816 title: res && (res.msg || res.message) || '打卡失败', 1019 title: res && (res.msg || res.message) || '打卡失败',
817 icon: 'none' 1020 icon: 'none'
818 }); 1021 });
819 - case 26:  
820 - _context5.next = 31; 1022 + case 34:
  1023 + _context5.next = 39;
821 break; 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 uni.showToast({ 1028 uni.showToast({
826 title: '打卡失败', 1029 title: '打卡失败',
827 icon: 'none' 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 case "end": 1037 case "end":
835 return _context5.stop(); 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 \ No newline at end of file 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 \ No newline at end of file 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,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 var _default = { 234 var _default = {
223 data: function data() { 235 data: function data() {
224 return { 236 return {
225 loading: false, 237 loading: false,
226 list: [], 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 query: { 253 query: {
228 currentPage: 1, 254 currentPage: 1,
229 pageSize: 20, 255 pageSize: 20,
@@ -234,9 +260,17 @@ var _default = { @@ -234,9 +260,17 @@ var _default = {
234 }; 260 };
235 }, 261 },
236 computed: { 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 displayList: function displayList() { 271 displayList: function displayList() {
238 var _this = this; 272 var _this = this;
239 - return (this.list || []).map(function (item) { 273 + return (this.filteredList || []).map(function (item) {
240 return _objectSpread(_objectSpread({}, item), {}, { 274 return _objectSpread(_objectSpread({}, item), {}, {
241 statusClassName: _this.statusClass(item.status), 275 statusClassName: _this.statusClass(item.status),
242 statusText: _this.getStatusText(item.status), 276 statusText: _this.getStatusText(item.status),
@@ -305,6 +339,11 @@ var _default = { @@ -305,6 +339,11 @@ var _default = {
305 }, _callee, null, [[1, 11, 16, 20]]); 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 refresh: function refresh() { 347 refresh: function refresh() {
309 this.query.currentPage = 1; 348 this.query.currentPage = 1;
310 this.total = 0; 349 this.total = 0;
@@ -341,6 +380,15 @@ var _default = { @@ -341,6 +380,15 @@ var _default = {
341 }; 380 };
342 return map[Number(status)] || 'status-default'; 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 getUrgentText: function getUrgentText(val) { 392 getUrgentText: function getUrgentText(val) {
345 var map = { 393 var map = {
346 1: '普通', 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 \ No newline at end of file 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 \ No newline at end of file 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,6 +30,28 @@
30 box-sizing: border-box; 30 box-sizing: border-box;
31 background: linear-gradient(180deg, #e8f5e9 0%, #f7fff8 100%); 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 .header-card.data-v-3bb121e5, 55 .header-card.data-v-3bb121e5,
34 .state-card.data-v-3bb121e5, 56 .state-card.data-v-3bb121e5,
35 .apply-card.data-v-3bb121e5 { 57 .apply-card.data-v-3bb121e5 {
项目文档相关/docs/数据库说明.md
@@ -56,6 +56,11 @@ @@ -56,6 +56,11 @@
56 - `longitude` (DECIMAL): 经度 56 - `longitude` (DECIMAL): 经度
57 - `latitude` (DECIMAL): 纬度 57 - `latitude` (DECIMAL): 纬度
58 - `fence_polygons` (JSON): 电子围栏多边形坐标,格式 `[[{lng,lat},...]]` 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 - `F_BusinessHours` (TEXT): 营业时间设置 64 - `F_BusinessHours` (TEXT): 营业时间设置
60 - `F_TrafficTips` (TEXT): 交通提示 65 - `F_TrafficTips` (TEXT): 交通提示
61 - **人员信息**: `BASE_USER` (系统用户表,包含门店ID等扩展字段) 66 - **人员信息**: `BASE_USER` (系统用户表,包含门店ID等扩展字段)
@@ -161,7 +166,7 @@ @@ -161,7 +166,7 @@
161 - `F_PunchOutLongitude` / `F_PunchOutLatitude` / `F_PunchOutAddress`: 下班打卡定位与地址 166 - `F_PunchOutLongitude` / `F_PunchOutLatitude` / `F_PunchOutAddress`: 下班打卡定位与地址
162 - `F_PunchInPhotoUrl` / `F_PunchOutPhotoUrl`: 上下班打卡照片 167 - `F_PunchInPhotoUrl` / `F_PunchOutPhotoUrl`: 上下班打卡照片
163 - `F_IsPunchInFenceValid` / `F_IsPunchOutFenceValid`: 上下班围栏校验结果(1围栏内,0围栏外,null未校验) 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 - `F_LateMinutes`: 迟到分钟数 170 - `F_LateMinutes`: 迟到分钟数
166 - `F_EarlyLeaveMinutes`: 早退分钟数 171 - `F_EarlyLeaveMinutes`: 早退分钟数
167 - `F_IsManual`: 是否手动补录 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`;