feat: 新增考勤模板管理功能

- 后端新增 WmsAttendanceTemplate CRUD(Controller/Service/Mapper/Domain)
- 前端新增 attendanceTemplate API 对接
- 前端新增 AttendanceTemplateManager 组件(拖拽排序 + 模板编辑)
- 优化考勤 drag.vue 页面交互
This commit is contained in:
2026-06-06 17:08:31 +08:00
parent 696f6d9ee0
commit 1a2fc9852d
11 changed files with 969 additions and 58 deletions

View File

@@ -1,11 +1,28 @@
<template>
<div class="template-manager">
<div class="left-panel">
<div class="custom-tabs">
<div
:class="['custom-tab', { active: activeTab === 'shared' }]"
@click="switchTab('shared')"
>共享模板</div>
<div
:class="['custom-tab', { active: activeTab === 'personal' }]"
@click="switchTab('personal')"
>自用模板</div>
</div>
<div class="panel-header">
<span class="panel-title">模板列表</span>
<el-button icon="el-icon-plus" size="mini" type="primary" @click="addTemplate">新增</el-button>
<div class="panel-header-actions">
<el-button icon="el-icon-plus" size="mini" type="primary" @click="addTemplate">新增</el-button>
<el-button icon="el-icon-download" size="mini" type="success" @click="exportCsv" :disabled="!currentTemplate.id">导出</el-button>
<el-button icon="el-icon-upload2" size="mini" type="info" @click="importCsv">导入</el-button>
</div>
</div>
<div class="template-list">
<div class="shared-toolbar" v-show="activeTab === 'shared'">
<el-input v-model="sharedQueryParams.templateName" placeholder="搜索模板名称" size="small" clearable prefix-icon="el-icon-search" @input="handleSearchTemplate" style="width: 200px;" />
</div>
<div class="template-list" v-loading="templateLoading">
<div
v-for="template in templateList"
:key="template.id"
@@ -25,7 +42,18 @@
<el-empty description="暂无模板" />
</div>
</div>
<div class="shared-pagination" v-show="activeTab === 'shared'">
<el-pagination
background
layout="prev, pager, next"
:current-page="sharedQueryParams.pageNum"
:page-size="sharedQueryParams.pageSize"
:total="sharedTotal"
@current-change="handleSharedPageChange"
/>
</div>
</div>
<input type="file" ref="csvInput" accept=".csv" style="display:none" @change="handleCsvImport" />
<div class="right-panel">
<div class="panel-header">
<span class="panel-title">员工配置</span>
@@ -62,6 +90,8 @@
</template>
<script>
import { listAttendanceTemplate, addAttendanceTemplate, updateAttendanceTemplate, delAttendanceTemplate } from '@/api/wms/attendanceTemplate'
export default {
name: 'AttendanceTemplateManager',
props: {
@@ -72,7 +102,16 @@ export default {
},
data() {
return {
templateList: [],
activeTab: 'shared',
sharedTemplateList: [],
sharedTemplateLoading: false,
sharedQueryParams: {
pageNum: 1,
pageSize: 20,
templateName: ''
},
sharedTotal: 0,
personalTemplateList: [],
currentTemplate: {
id: '',
name: '',
@@ -83,22 +122,202 @@ export default {
selectedEmployeeIds: [],
}
},
computed: {
templateList() {
return this.activeTab === 'shared' ? this.sharedTemplateList : this.personalTemplateList
},
templateLoading() {
return this.activeTab === 'shared' ? this.sharedTemplateLoading : false
}
},
mounted() {
this.loadTemplates()
},
methods: {
loadTemplates() {
try {
const templates = localStorage.getItem('attendanceTemplates')
this.templateList = templates ? JSON.parse(templates) : []
} catch (e) {
this.templateList = []
if (this.activeTab === 'shared') {
this.sharedTemplateLoading = true
const query = {
pageNum: this.sharedQueryParams.pageNum,
pageSize: this.sharedQueryParams.pageSize
}
if (this.sharedQueryParams.templateName) {
query.templateName = this.sharedQueryParams.templateName
}
listAttendanceTemplate(query).then(res => {
this.sharedTemplateList = (res.rows || []).map(row => ({
id: row.templateId,
name: row.templateName,
employeeIds: this.parseTemplateContent(row.templateContent),
employeeCount: this.parseTemplateContent(row.templateContent).length,
createTime: row.createTime
}))
this.sharedTotal = res.total || 0
}).finally(() => {
this.sharedTemplateLoading = false
})
} else {
try {
const templates = localStorage.getItem('attendanceTemplates')
this.personalTemplateList = templates ? JSON.parse(templates) : []
} catch (e) {
this.personalTemplateList = []
}
}
},
saveTemplates() {
localStorage.setItem('attendanceTemplates', JSON.stringify(this.templateList))
this.$emit('update')
parseTemplateContent(content) {
if (!content) return []
try {
const parsed = JSON.parse(content)
return Array.isArray(parsed) ? parsed : []
} catch (e) {
return content.split(',').filter(Boolean)
}
},
buildTemplateContent(employeeIds) {
return JSON.stringify(employeeIds)
},
handleSearchTemplate() {
this.sharedQueryParams.pageNum = 1
this.loadTemplates()
},
handleSharedPageChange(pageNum) {
this.sharedQueryParams.pageNum = pageNum
this.loadTemplates()
},
switchTab(tab) {
if (this.activeTab === tab) return
this.activeTab = tab
this.loadTemplates()
this.resetForm()
},
savePersonalTemplates() {
localStorage.setItem('attendanceTemplates', JSON.stringify(this.personalTemplateList))
},
exportCsv() {
if (!this.currentTemplate.id || !this.currentTemplate.employeeIds.length) {
this.$message.warning('当前模板没有员工可导出')
return
}
const empMap = {}
this.employeeList.forEach(emp => {
empMap[emp.key] = emp.label
})
let csvContent = '\uFEFF员工ID,员工姓名\n'
this.currentTemplate.employeeIds.forEach(id => {
const name = empMap[id] || id
csvContent += `${id},${name}\n`
})
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${this.currentTemplate.name}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
},
importCsv() {
this.$refs.csvInput.click()
},
handleCsvImport(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = async (e) => {
const content = e.target.result
const lines = content.split('\n').filter(line => line.trim())
if (lines.length === 0) {
this.$message.warning('CSV文件内容为空')
event.target.value = ''
return
}
const header = lines[0].trim()
if (header !== '员工ID,员工姓名' && header !== '员工ID\t员工姓名') {
this.$message.warning('CSV文件格式不正确表头应为"员工ID,员工姓名"')
event.target.value = ''
return
}
const empKeySet = new Set()
const empLabelMap = {}
this.employeeList.forEach(emp => {
empKeySet.add(String(emp.key))
empLabelMap[emp.label] = emp.key
})
const matchedIds = []
const notFoundNames = []
const lines_data = lines.slice(1)
for (const line of lines_data) {
const trimmed = line.trim()
if (!trimmed) continue
const parts = trimmed.includes('\t') ? trimmed.split('\t') : trimmed.split(',')
const key = parts[0] ? parts[0].trim() : ''
const label = parts.length > 1 ? parts.slice(1).join(',').trim() : key
if (key && empKeySet.has(String(key))) {
matchedIds.push(key)
} else if (label && empLabelMap[label] !== undefined) {
matchedIds.push(empLabelMap[label])
} else {
notFoundNames.push(label || key)
}
}
if (matchedIds.length === 0) {
this.$message.warning('CSV文件中没有匹配到任何员工')
event.target.value = ''
return
}
const templateName = file.name.replace(/\.csv$/i, '')
if (this.activeTab === 'shared') {
await addAttendanceTemplate({
templateName: templateName,
templateContent: this.buildTemplateContent(matchedIds)
})
this.loadTemplates()
const newTemplate = this.sharedTemplateList.find(t => t.name === templateName)
if (newTemplate) {
this.currentTemplate = JSON.parse(JSON.stringify(newTemplate))
this.selectedEmployeeIds = [...newTemplate.employeeIds]
}
} else {
const templateData = {
id: Date.now().toString(),
name: templateName,
employeeIds: matchedIds,
employeeCount: matchedIds.length,
createTime: new Date().toLocaleString('zh-CN')
}
this.personalTemplateList.push(templateData)
this.savePersonalTemplates()
this.currentTemplate = JSON.parse(JSON.stringify(templateData))
this.selectedEmployeeIds = [...matchedIds]
}
this.$emit('update')
if (notFoundNames.length > 0) {
this.$message.warning(`成功导入${matchedIds.length}名员工,${notFoundNames.length}名未匹配:${notFoundNames.join('、')}`)
} else {
this.$message.success(`成功导入${matchedIds.length}名员工`)
}
}
reader.readAsText(file, 'UTF-8')
event.target.value = ''
},
addTemplate() {
@@ -118,20 +337,29 @@ export default {
},
deleteTemplate(template) {
this.$confirm('确定删除?', '提示', {
const confirmMsg = this.activeTab === 'shared'
? '删除后该模板所有人都无法使用,确定删除?'
: '确定删除?'
this.$confirm(confirmMsg, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const index = this.templateList.findIndex(t => t.id === template.id)
if (index > -1) {
this.templateList.splice(index, 1)
this.saveTemplates()
this.$message.success('删除成功')
if (this.currentTemplate.id === template.id) {
this.resetForm()
}).then(async () => {
if (this.activeTab === 'shared') {
await delAttendanceTemplate(template.id)
} else {
const index = this.personalTemplateList.findIndex(t => t.id === template.id)
if (index > -1) {
this.personalTemplateList.splice(index, 1)
this.savePersonalTemplates()
}
}
this.$message.success('删除成功')
if (this.currentTemplate.id === template.id) {
this.resetForm()
}
this.loadTemplates()
this.$emit('update')
}).catch(() => {
this.$message.info('已取消')
})
@@ -142,32 +370,52 @@ export default {
this.selectedEmployeeIds = [...template.employeeIds]
},
saveTemplate() {
async saveTemplate() {
if (!this.currentTemplate.name.trim()) {
this.$message.warning('请输入模板名称')
return
}
const templateData = {
id: this.currentTemplate.id || Date.now().toString(),
name: this.currentTemplate.name.trim(),
employeeIds: [...this.selectedEmployeeIds],
employeeCount: this.selectedEmployeeIds.length,
createTime: this.currentTemplate.id ? this.currentTemplate.createTime : new Date().toLocaleString('zh-CN')
}
if (this.currentTemplate.id) {
const index = this.templateList.findIndex(t => t.id === this.currentTemplate.id)
if (index > -1) {
this.templateList[index] = templateData
if (this.activeTab === 'shared') {
const apiData = {
templateName: this.currentTemplate.name.trim(),
templateContent: this.buildTemplateContent(this.selectedEmployeeIds)
}
this.$message.success('修改成功')
} else {
this.templateList.push(templateData)
this.$message.success('新增成功')
}
this.saveTemplates()
if (this.currentTemplate.id) {
apiData.templateId = this.currentTemplate.id
await updateAttendanceTemplate(apiData)
this.$message.success('修改成功')
} else {
await addAttendanceTemplate(apiData)
this.$message.success('新增成功')
}
this.loadTemplates()
this.$emit('update')
} else {
const templateData = {
id: this.currentTemplate.id || Date.now().toString(),
name: this.currentTemplate.name.trim(),
employeeIds: [...this.selectedEmployeeIds],
employeeCount: this.selectedEmployeeIds.length,
createTime: this.currentTemplate.id ? this.currentTemplate.createTime : new Date().toLocaleString('zh-CN')
}
if (this.currentTemplate.id) {
const index = this.personalTemplateList.findIndex(t => t.id === this.currentTemplate.id)
if (index > -1) {
this.personalTemplateList[index] = templateData
}
this.$message.success('修改成功')
} else {
this.personalTemplateList.push(templateData)
this.$message.success('新增成功')
}
this.savePersonalTemplates()
this.$emit('update')
}
},
resetForm() {
@@ -220,6 +468,21 @@ export default {
color: #303133;
}
.panel-header-actions {
display: flex;
gap: 6px;
}
.shared-toolbar {
margin-bottom: 10px;
}
.shared-pagination {
display: flex;
justify-content: center;
padding: 10px 0 4px;
}
.template-list {
flex: 1;
overflow-y: auto;
@@ -315,4 +578,31 @@ export default {
padding-top: 10px;
border-top: 1px solid #e4e7ed;
}
.custom-tabs {
display: flex;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 12px;
}
.custom-tab {
padding: 6px 14px;
font-size: 13px;
color: #909399;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
user-select: none;
}
.custom-tab:hover {
color: #606266;
}
.custom-tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
font-weight: 500;
}
</style>