feat: add Leaves management page (PC)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
|
||||
import { useScreenshot } from '@/utils/screenshot'
|
||||
import { leavesApi } from '@/api/leaves'
|
||||
import api from '@/api/index'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const leaves = ref<any[]>([])
|
||||
const allUsers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const form = ref<any>({})
|
||||
const leaveTypes = ['年假', '事假', '病假', '调休', '其他']
|
||||
const leaveTypeColors: Record<string, string> = {
|
||||
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
|
||||
'调休': '#C4934A', '其他': '#7B7568',
|
||||
}
|
||||
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return leaves.value
|
||||
.map((l: any) => l.manager_name || '未知')
|
||||
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
|
||||
.sort()
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '进行中', value: 'active' },
|
||||
{ label: '即将开始', value: 'upcoming' },
|
||||
{ label: '已结束', value: 'past' },
|
||||
]
|
||||
|
||||
const filteredLeaves = computed(() => {
|
||||
let list = leaves.value
|
||||
if (filterManager.value) list = list.filter((l: any) => (l.manager_name || '未知') === filterManager.value)
|
||||
return list
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filterManager.value = ''
|
||||
filterStatus.value = ''
|
||||
}
|
||||
|
||||
const leaveTypeSummary = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
leaves.value.forEach((l: any) => {
|
||||
map[l.leave_type] = (map[l.leave_type] || 0) + 1
|
||||
})
|
||||
return Object.entries(map).sort((a, b) => b[1] - a[1])
|
||||
})
|
||||
|
||||
function isLeaveActive(row: any): boolean {
|
||||
return row.start_date <= today && row.end_date >= today
|
||||
}
|
||||
|
||||
function tableRowClass({ row }: any) {
|
||||
return isLeaveActive(row) ? 'leave-row--active' : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadLeaves(), loadUsers(), loadManagerColors()])
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await api.get('/users/?role=manager')
|
||||
allUsers.value = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadLeaves() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
const res = await leavesApi.list(params)
|
||||
leaves.value = res.data.items || []
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = {
|
||||
manager_id: auth.isDirector ? '' : auth.userId,
|
||||
leave_type: '事假',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reason: '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
dialogMode.value = 'edit'
|
||||
form.value = { ...item }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.start_date || !form.value.end_date) {
|
||||
ElMessage.warning('请选择日期范围')
|
||||
return
|
||||
}
|
||||
if (form.value.start_date > form.value.end_date) {
|
||||
ElMessage.warning('结束日期不能早于开始日期')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
manager_id: form.value.manager_id,
|
||||
leave_type: form.value.leave_type,
|
||||
start_date: form.value.start_date,
|
||||
end_date: form.value.end_date,
|
||||
reason: form.value.reason || '',
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await leavesApi.create(payload)
|
||||
} else {
|
||||
await leavesApi.update(form.value.id, payload)
|
||||
}
|
||||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||||
dialogVisible.value = false
|
||||
await loadLeaves()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await leavesApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
await loadLeaves()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function handleScreenshot() {
|
||||
const d = new Date().toISOString().slice(0, 10)
|
||||
await captureEl(shotRef.value, `请假管理_${d}.png`)
|
||||
}
|
||||
|
||||
function onStatusChange(val: string) {
|
||||
filterStatus.value = val
|
||||
loadLeaves()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="leaves-page" v-loading="loading">
|
||||
<div ref="shotRef">
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<div>
|
||||
<h2 class="page-title">请假管理</h2>
|
||||
<p class="page-desc">管理客户经理请假记录,请假期间豁免填报考核。</p>
|
||||
</div>
|
||||
<div class="page-head-actions">
|
||||
<el-button type="primary" @click="openCreate" class="screenshot-hide">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
新建请假
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
截图导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
<div v-if="leaveTypeSummary.length" class="summary-bar">
|
||||
<span class="summary-label">请假类型汇总</span>
|
||||
<span v-for="[type, count] in leaveTypeSummary" :key="type" class="summary-chip" :style="{ background: leaveTypeColors[type] + '20', color: leaveTypeColors[type], border: '1px solid ' + leaveTypeColors[type] + '40' }">{{ type }} · {{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">状态</label>
|
||||
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px" @change="onStatusChange">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button v-if="filterManager" size="small" plain @click="resetFilters">重置</el-button>
|
||||
<span class="filter-count">{{ filteredLeaves.length }} / {{ leaves.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="filteredLeaves" stripe size="small" v-if="filteredLeaves.length" :row-class-name="tableRowClass" v-column-resize>
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column label="姓名" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<span class="leave-type-chip" :style="{ background: leaveTypeColors[row.leave_type] || '#7B7568', color: '#fff', padding: '2px 10px', fontSize: '12px' }">{{ row.leave_type }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期范围" width="220">
|
||||
<template #default="{ row }">{{ row.start_date }} ~ {{ row.end_date }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="天数" width="60" align="center">
|
||||
<template #default="{ row }">{{ row.days }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="提交人" width="100">
|
||||
<template #default="{ row }">{{ row.submitted_by_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" class-name="screenshot-hide">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!leaves.length" class="empty">暂无请假记录</div>
|
||||
<div v-else-if="leaves.length && !filteredLeaves.length" class="empty">无匹配结果</div>
|
||||
</el-card>
|
||||
</div><!-- /shotRef -->
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 请假'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item v-if="auth.isDirector" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="请假类型">
|
||||
<div class="leave-type-grid">
|
||||
<button
|
||||
v-for="t in leaveTypes" :key="t"
|
||||
type="button"
|
||||
class="leave-type-chip-btn"
|
||||
:class="{ 'leave-type-chip-btn--active': form.leave_type === t }"
|
||||
:style="form.leave_type === t ? { background: leaveTypeColors[t], borderColor: leaveTypeColors[t], color: '#fff' } : {}"
|
||||
@click="form.leave_type = t"
|
||||
>{{ t }}</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="请假日期">
|
||||
<el-date-picker
|
||||
v-model="form.start_date"
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
style="width:100%; margin-bottom: 8px"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="form.end_date"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
style="width:100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因(选填)">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请假原因..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head { margin-bottom: 20px; }
|
||||
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: var(--font-body); }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
|
||||
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
|
||||
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; white-space: nowrap; }
|
||||
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
|
||||
|
||||
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||||
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||||
|
||||
.leave-type-grid { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.leave-type-chip-btn {
|
||||
flex: 1; min-width: 60px; padding: 10px 8px;
|
||||
background: var(--surface); border: 1px solid var(--warm-border);
|
||||
font-family: var(--font-body); font-size: 13px; letter-spacing: 0.04em;
|
||||
cursor: pointer; transition: all 0.25s; text-align: center;
|
||||
}
|
||||
.leave-type-chip-btn:hover { border-color: var(--ink); }
|
||||
.leave-type-chip-btn--active { font-weight: 600; }
|
||||
|
||||
:deep(.leave-row--active) {
|
||||
border-left: 3px solid #5B7FA5 !important;
|
||||
background: rgba(91, 127, 165, 0.04) !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user