Files
qiji/frontend/src/views/desktop/Leaves.vue
T
v6ole 7f87aa7353 fix: 请假表单日期序列化与错误消息格式化修复
- el-date-picker 添加 value-format="YYYY-MM-DD",确保日期以字符串格式发送而非 ISO datetime,修复后端 Pydantic date 校验 422 错误
- 错误处理中正确解析 FastAPI 422 返回的 detail 数组,提取 msg 字段拼接显示,修复 [object Object] 显示问题

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:02:26 +08:00

338 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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.mode === '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) {
const detail = e.response?.data?.detail
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
ElMessage.error('保存失败: ' + msg)
}
}
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"
value-format="YYYY-MM-DD"
/>
<el-date-picker
v-model="form.end_date"
type="date"
placeholder="结束日期"
style="width:100%"
value-format="YYYY-MM-DD"
/>
</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;
}
.required-star { color: var(--vermilion); font-weight: 700; }
</style>