<script setup lang="ts">
|
import noDataImg from '@/assets/element-img/no-data.png'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { addCheckLocation, getCheckLocationList } from '@/service/api/attendance'
|
import AddressFormModal from '@/views/operations/attendance/address/components/AddressFormModal.vue'
|
|
const props = defineProps<{
|
show: boolean
|
editPoint?: {
|
id: string
|
pointId: number
|
timeLimit: string
|
name?: string
|
range?: string
|
} | null
|
}>()
|
|
const emit = defineEmits<{
|
'update:show': [value: boolean]
|
'confirm': [data: any]
|
}>()
|
|
const show = computed({
|
get: () => props.show,
|
set: value => emit('update:show', value),
|
})
|
|
const pointId = ref<number | null>(null)
|
const startTime = ref<string | null>(null)
|
const endTime = ref<string | null>(null)
|
const isPointSelectOpen = ref(false)
|
const showAddressFormModal = ref(false)
|
|
const pointOptions = ref<Array<{ label: string, value: number, range: string }>>([])
|
|
async function getPointOptions() {
|
const res = await getCheckLocationList({
|
pageNum: 1,
|
pageSize: 99999,
|
type: 2,
|
})
|
if (res.code === 200) {
|
const records = Array.isArray(res.data?.records) ? res.data.records : []
|
pointOptions.value = records.map((item: any) => ({
|
label: item.locationAddress,
|
value: item.id,
|
range: item.locationRange,
|
}))
|
}
|
}
|
|
function handleAddPoint() {
|
showAddressFormModal.value = true
|
}
|
|
async function handleConfirmCreatePoint(payload: any) {
|
try {
|
const res = await addCheckLocation({
|
...payload,
|
type: 2,
|
})
|
if (res.code === 200) {
|
window.$message.success('新增打卡点成功')
|
await getPointOptions()
|
|
// 创建后自动回填到下拉:优先用返回ID,其次按地址/范围匹配
|
const createdId = Number(res.data?.id ?? res.data?.locationId ?? 0)
|
if (createdId) {
|
pointId.value = createdId
|
}
|
else {
|
const createdAddress = String(payload?.locationAddress || '')
|
const createdRange = Number(payload?.locationRange ?? Number.NaN)
|
const matched = pointOptions.value
|
.find(item =>
|
item.label === createdAddress
|
&& Number(item.range) === createdRange,
|
)
|
if (matched) {
|
pointId.value = matched.value
|
}
|
}
|
return
|
}
|
window.$message.warning(res.msg || '新增打卡点失败')
|
}
|
catch (error) {
|
console.error('新增打卡点失败:', error)
|
// window.$message.error('新增打卡点失败')
|
}
|
}
|
|
function parseTimeToMinute(time: string) {
|
const [hour, minute] = time.split(':').map(Number)
|
return hour * 60 + minute
|
}
|
|
// 禁止选择小于开始时间的结束时间(结束时间面板直接置灰)
|
function isEndHourDisabled(hour: number) {
|
if (!startTime.value)
|
return false
|
const [startHour] = startTime.value.split(':').map(Number)
|
return hour < startHour
|
}
|
|
function isEndMinuteDisabled(minute: number, hour: number | null) {
|
if (!startTime.value || hour === null)
|
return false
|
const [startHour, startMinute] = startTime.value.split(':').map(Number)
|
return hour === startHour && minute < startMinute
|
}
|
|
function handleCancel() {
|
show.value = false
|
pointId.value = null
|
startTime.value = null
|
endTime.value = null
|
}
|
|
watch([startTime, endTime], ([start, end]) => {
|
if (!start || !end)
|
return
|
if (parseTimeToMinute(end) < parseTimeToMinute(start)) {
|
endTime.value = null
|
window.$message.warning('结束时间不能小于开始时间')
|
}
|
})
|
|
watch(show, async (visible) => {
|
if (!visible)
|
return
|
// 每次打开都刷新选项,避免编辑时 value 已有但 options 尚未加载导致不回显
|
await getPointOptions()
|
// 编辑场景:打开弹窗时回填点位和时间段
|
if (props.editPoint) {
|
const editPointId = props.editPoint.pointId || null
|
pointId.value = editPointId
|
// 当前点位不在下拉选项中时,补一个临时选项用于回显名称
|
if (editPointId && !pointOptions.value.some(item => item.value === editPointId)) {
|
pointOptions.value.unshift({
|
label: props.editPoint.name || `点位${editPointId}`,
|
value: editPointId,
|
range: String(props.editPoint.range || '-'),
|
})
|
}
|
const timeRangeMatch = String(props.editPoint.timeLimit || '').match(/^(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})$/)
|
startTime.value = timeRangeMatch?.[1] ?? null
|
endTime.value = timeRangeMatch?.[2] ?? null
|
return
|
}
|
// 新增场景:每次打开重置表单
|
pointId.value = null
|
startTime.value = null
|
endTime.value = null
|
})
|
|
function handleConfirm() {
|
if (!pointId.value) {
|
window.$message.warning('请选择打卡点')
|
return
|
}
|
|
const selectedPoint = pointOptions.value.find(p => p.value === pointId.value)
|
let timeStr = '-'
|
if (startTime.value && endTime.value) {
|
// 结束时间不能早于开始时间
|
if (endTime.value < startTime.value) {
|
window.$message.warning('结束时间不能小于开始时间')
|
return
|
}
|
timeStr = `${startTime.value}~${endTime.value}`
|
}
|
else if (startTime.value || endTime.value) {
|
window.$message.warning('请选择完整的打卡时间段')
|
return
|
}
|
|
emit('confirm', {
|
id: props.editPoint?.id,
|
pointId: pointId.value,
|
name: selectedPoint?.label,
|
range: selectedPoint?.range,
|
timeLimit: timeStr,
|
})
|
show.value = false
|
pointId.value = null
|
startTime.value = null
|
endTime.value = null
|
}
|
|
const lastTimePickerPanelUnit = ref<'hour' | 'minute' | 'second' | 'period' | null>(null)
|
|
function handleTimePickerPanelClick(event: Event) {
|
const target = event.target as HTMLElement | null
|
const panelItem = target?.closest('.n-time-picker-col__item')
|
if (!panelItem) {
|
lastTimePickerPanelUnit.value = null
|
return
|
}
|
|
const columnElement = panelItem.closest('.n-time-picker-col') as HTMLElement | null
|
const columnsContainer = columnElement?.parentElement
|
if (!columnElement || !columnsContainer) {
|
lastTimePickerPanelUnit.value = null
|
return
|
}
|
|
const columnElements = Array.from(columnsContainer.children).filter(element =>
|
(element as HTMLElement).classList.contains('n-time-picker-col'),
|
)
|
const columnIndex = columnElements.indexOf(columnElement)
|
const units: Array<'hour' | 'minute' | 'second' | 'period'> = ['hour', 'minute', 'second', 'period']
|
lastTimePickerPanelUnit.value = units[columnIndex] || null
|
}
|
|
function closeTimePicker() {
|
if (lastTimePickerPanelUnit.value !== 'minute')
|
return
|
lastTimePickerPanelUnit.value = null
|
if (document.activeElement) {
|
(document.activeElement as HTMLElement).blur()
|
}
|
}
|
|
onMounted(() => {
|
document.addEventListener('click', handleTimePickerPanelClick, true)
|
getPointOptions()
|
})
|
|
onBeforeUnmount(() => {
|
document.removeEventListener('click', handleTimePickerPanelClick, true)
|
})
|
</script>
|
|
<template>
|
<n-modal
|
v-model:show="show"
|
:mask-closable="false"
|
preset="card"
|
title="选择打卡点"
|
class="w-400px"
|
:auto-focus="false"
|
:segmented="{
|
content: true,
|
action: true,
|
}"
|
>
|
<div class="flex flex-col gap-16px pt-2">
|
<div>
|
<div class="mb-2">
|
<span class="text-red-500">*</span>打卡点
|
</div>
|
<div>
|
<n-select
|
v-model:value="pointId"
|
:options="pointOptions"
|
placeholder="请选择"
|
clearable
|
class="flex-1"
|
@update:show="isPointSelectOpen = $event"
|
>
|
<template #arrow>
|
<img
|
src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
|
class="arrow-icon"
|
:class="{ 'arrow-icon--open': isPointSelectOpen }"
|
style="width: 16px; height: 16px"
|
alt="arrow"
|
>
|
</template>
|
<template #empty>
|
<div class="flex-center">
|
<img
|
:src="noDataImg"
|
style="width: 50%"
|
alt="no-data"
|
>
|
</div>
|
</template>
|
</n-select>
|
<n-button type="primary" class="w-full mt-8px" dashed @click="handleAddPoint">
|
新增打卡点
|
</n-button>
|
</div>
|
</div>
|
|
<div>
|
<div class="mb-2">
|
打卡限时 <span class="text1">(不设置即代表不限时)</span>
|
</div>
|
<div class="flex items-center gap-2 time-range">
|
<n-time-picker
|
v-model:formatted-value="startTime"
|
format="HH:mm"
|
value-format="HH:mm"
|
placeholder="开始时间"
|
clearable
|
:actions="['now']"
|
class="custom-time-picker flex-1"
|
@update:value="closeTimePicker"
|
/>
|
<span style="color: #d9d9d9;">→</span>
|
<n-time-picker
|
v-model:formatted-value="endTime"
|
format="HH:mm"
|
value-format="HH:mm"
|
placeholder="结束时间"
|
clearable
|
:actions="['now']"
|
:is-hour-disabled="isEndHourDisabled"
|
:is-minute-disabled="isEndMinuteDisabled"
|
class="custom-time-picker flex-1"
|
@update:value="closeTimePicker"
|
/>
|
</div>
|
</div>
|
</div>
|
|
<template #action>
|
<n-space justify="end">
|
<n-button @click="handleCancel">
|
取消
|
</n-button>
|
<n-button type="primary" @click="handleConfirm">
|
保存
|
</n-button>
|
</n-space>
|
</template>
|
</n-modal>
|
|
<AddressFormModal
|
v-model:show="showAddressFormModal"
|
mode="add"
|
title="巡游打卡地"
|
:type="2"
|
@confirm="handleConfirmCreatePoint"
|
/>
|
</template>
|
|
<style scoped>
|
.arrow-icon {
|
transition: transform 0.2s;
|
user-select: none;
|
}
|
|
.arrow-icon--open {
|
transform: rotate(180deg);
|
}
|
|
.time-range {
|
width: 100%;
|
border: 1px solid #d9d9d9;
|
border-radius: 3px;
|
padding: 0;
|
transition: border-color 0.3s;
|
}
|
|
.time-range:hover {
|
border-color: #0068ff;
|
}
|
|
:deep(.custom-time-picker .n-input) {
|
border: none !important;
|
box-shadow: none !important;
|
background-color: transparent;
|
}
|
|
:deep(.custom-time-picker .n-input:hover) {
|
border: none !important;
|
box-shadow: none !important;
|
}
|
|
:deep(.custom-time-picker .n-input--focus) {
|
border: none !important;
|
box-shadow: none !important;
|
}
|
|
:deep(.custom-time-picker .n-input .n-input__state-border) {
|
display: none !important;
|
}
|
|
:deep(.custom-time-picker .n-input .n-input__border) {
|
display: none !important;
|
}
|
|
.text1 {
|
color: rgba(23, 31, 38, 0.6)
|
}
|
</style>
|