Files
tongjiweb/client/src/views/admin/Dashboard.vue
T

1450 lines
48 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.
<template>
<div class="dashboard-page">
<div class="page-header" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div>
<h1>全局统计看板</h1>
<p>多维度查看工时数据销售视角 / 研发视角 / 项目视角</p>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<el-date-picker v-model="allExportDateRange" type="daterange" range-separator="至"
start-placeholder="全部时间起" end-placeholder="全部时间止" value-format="YYYY-MM-DD" size="large" />
<el-button type="success" @click="exportData('all')" :loading="exportingAll" size="large">
📊 总导出 (All in One)
</el-button>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" type="border-card" class="dashboard-tabs">
<!-- ==================== Sales Perspective ==================== -->
<el-tab-pane label="📈 销售视角" name="sales">
<div class="filter-bar">
<el-date-picker v-model="salesFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
<el-button type="primary" @click="loadSalesData">查询</el-button>
<el-button @click="exportData('sales')">📥 导出该页</el-button>
</div>
<div class="two-col-layout">
<!-- Sales Table -->
<div class="table-col">
<div class="section-title">📋 销售人员汇总 (点击展开查看研发明细)</div>
<el-table :data="salesSummary" stripe v-loading="salesLoading" style="width: 100%" row-key="sales_name">
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: rgba(0,0,0,0.2);">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--text-secondary);">
[ {{ props.row.sales_name }} ] 的研发工时消耗明细
</div>
<el-table :data="props.row.rdList" border style="width: 100%" size="small">
<el-table-column prop="rd_name" label="研发人员" width="150" />
<el-table-column prop="total_hours" label="消耗工时(h)" width="120" />
<el-table-column prop="work_days" label="工作天数" width="120" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="sales_name" label="销售人员" min-width="140" />
<el-table-column prop="project_count" label="项目数" min-width="100" />
<el-table-column prop="rd_count" label="涉及研发" min-width="120" />
<el-table-column prop="total_hours" label="总消耗工时(h)" min-width="130" sortable />
</el-table>
</div>
<!-- Sales Charts -->
<div class="chart-col">
<div class="chart-card">
<div class="chart-title">销售占用工时占比饼图</div>
<div ref="salesChartRef" class="big-chart"></div>
</div>
<div class="chart-card" style="margin-top: 16px">
<div class="chart-title">各销售工时对比柱状图</div>
<div ref="salesBarRef" class="big-chart"></div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== R&D Perspective ==================== -->
<el-tab-pane label="👨‍💻 研发视角" name="rd">
<div class="filter-bar">
<el-date-picker v-model="rdFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
<el-select v-model="rdFilter.user_id" placeholder="全部研发" clearable size="default" style="width: 150px">
<el-option v-for="u in rdUsers" :key="u.id" :label="u.realname" :value="u.id" />
</el-select>
<el-button type="primary" @click="loadRdData">查询</el-button>
<el-button @click="exportData('rd')">📥 导出该页</el-button>
</div>
<div class="two-col-layout">
<!-- R&D Table -->
<div class="table-col">
<div class="section-title">📋 研发人员填报明细</div>
<el-table :data="rdData" stripe v-loading="rdLoading" style="width: 100%">
<el-table-column prop="user_name" label="研发人员" min-width="120" />
<el-table-column prop="client_name" label="客户/项目" min-width="180" />
<el-table-column prop="work_date" label="日期" min-width="120" />
<el-table-column prop="hours" label="工时(h)" min-width="100" />
<el-table-column prop="remark" label="备注" min-width="180" />
</el-table>
</div>
<!-- R&D Charts -->
<div class="chart-col">
<div class="chart-card">
<div class="chart-title">研发人员工时产出占比饼图</div>
<div ref="rdChartRef" class="big-chart"></div>
</div>
<div class="chart-card" style="margin-top: 16px">
<div class="chart-title">各研发人员工时对比柱状图</div>
<div ref="rdBarRef" class="big-chart"></div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== Project Perspective ==================== -->
<el-tab-pane label="🏢 项目视角" name="project">
<div class="filter-bar">
<el-date-picker v-model="projFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" style="margin-right: 12px;" />
<el-select v-model="projFilter.sales_id" clearable placeholder="全部销售" style="width: 140px; margin-right: 12px;">
<el-option v-for="u in salesUsers" :key="u.id" :label="u.realname" :value="u.id" />
</el-select>
<el-button type="primary" @click="loadProjectData">查询</el-button>
<el-button @click="exportData('project')">📥 导出该页</el-button>
</div>
<div class="two-col-layout">
<!-- Project Table -->
<div class="table-col">
<div class="section-title">📋 项目总览 (点击展开查看研发明细)</div>
<el-table :data="projectData" stripe v-loading="projLoading" style="width: 100%" row-key="client_id">
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: rgba(0,0,0,0.2);">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--text-secondary);">
[ {{ props.row.client_name }} ] 的参与研发明细
</div>
<el-table :data="props.row.rdList" border style="width: 100%" size="small">
<el-table-column prop="rd_name" label="研发人员" width="150" />
<el-table-column prop="total_hours" label="消耗工时(h)" width="120" sortable />
<el-table-column prop="work_days" label="工作天数" width="120" />
<el-table-column prop="first_date" label="首次介入" width="120" />
<el-table-column prop="last_date" label="最近介入" width="120" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="client_name" label="客户/项目" min-width="200" />
<el-table-column prop="sales_name" label="所属销售" min-width="120" />
<el-table-column prop="client_status" label="状态" min-width="90">
<template #default="{ row }">
<el-tag :type="row.client_status === 1 ? 'success' : 'info'" effect="dark" round>
{{ row.client_status === 1 ? '进行中' : '已结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_hours" label="总工时(h)" min-width="110" sortable />
<el-table-column prop="rd_count" label="研发人数" min-width="100" />
</el-table>
</div>
<!-- Project Charts -->
<div class="chart-col">
<div class="chart-card">
<div class="chart-title">项目工时分布饼图</div>
<div ref="projectChartRef" class="big-chart"></div>
</div>
<div class="chart-card" style="margin-top: 16px">
<div class="chart-title">各项目工时对比柱状图</div>
<div ref="projectBarRef" class="big-chart"></div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== Matrix Summary ==================== -->
<el-tab-pane label="📊 研发工时汇总" name="matrix">
<div class="filter-bar">
<el-date-picker v-model="matrixFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
<el-button type="primary" @click="loadMatrixData">查询</el-button>
<el-button @click="exportMatrix">📥 导出该页</el-button>
</div>
<div class="content-card" style="margin-top: 16px; overflow-x: auto;" v-loading="matrixLoading">
<table class="matrix-table" v-if="matrixRows.length">
<thead>
<tr>
<th class="row-header">研发人员</th>
<th v-for="col in matrixCols" :key="col">{{ col }}</th>
<th class="total-col">合计</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in matrixRows" :key="ri" :class="{ 'total-row': ri === matrixRows.length - 1 }">
<td class="row-header">{{ row.label }}</td>
<td v-for="col in matrixCols" :key="col">{{ row.cells[col] || '-' }}</td>
<td class="total-col">{{ row.total }}</td>
</tr>
</tbody>
</table>
<el-empty v-else description="暂无工时数据" />
</div>
</el-tab-pane>
<!-- ==================== Fill-out Calendar ==================== -->
<el-tab-pane label="📅 填报日历" name="calendar">
<div class="filter-bar">
<el-date-picker v-model="calendarFilter.month" type="month" placeholder="选择月份"
value-format="YYYY-MM" size="default" :clearable="false" @change="handleCalendarFilterChange" style="width: 140px; margin-right: 12px;" />
<el-radio-group v-model="calendarViewMode" size="default" style="margin-right: 12px;">
<el-radio-button value="grid">全员概览</el-radio-button>
<el-radio-button value="individual">个人日历</el-radio-button>
</el-radio-group>
<el-select v-if="calendarViewMode === 'individual'" v-model="calendarFilter.user_id" placeholder="选择研发人员" size="default" style="width: 150px" @change="loadIndividualCalendarData">
<el-option v-for="u in rdUsers" :key="u.id" :label="u.realname" :value="u.id" />
</el-select>
</div>
<!-- 1. All Staff Heatmap Grid (全员概览) -->
<div v-if="calendarViewMode === 'grid'" class="content-card" style="margin-top: 16px; overflow-x: auto;" v-loading="calendarLoading">
<div class="card-title">
<span>👥 R&D 全员填报状态矩阵 ({{ calendarFilter.month }})</span>
<div class="calendar-legends">
<span class="legend-item"><span class="dot green"></span>已满8h</span>
<span class="legend-item"><span class="dot yellow"></span>未满8h</span>
<span class="legend-item"><span class="dot red"></span>漏填报</span>
<span class="legend-item"><span class="dot gray"></span>周末/未到期</span>
</div>
</div>
<table class="grid-table" v-if="gridData.length">
<thead>
<tr>
<th class="name-header">姓名</th>
<th v-for="day in daysInSelectedMonth" :key="day" class="day-col">
{{ day }}
<span class="day-weekday">{{ getDayLabel(day) }}</span>
</th>
<th class="total-header">累计工时</th>
</tr>
</thead>
<tbody>
<tr v-for="row in gridData" :key="row.user_id" class="grid-row">
<td class="name-header clickable" @click="switchToIndividual(row.user_id)">
{{ row.user_name }}
<span class="arrow-tip">📅</span>
</td>
<td v-for="day in daysInSelectedMonth" :key="day"
:class="['status-cell', getGridCellClass(row, day)]"
:title="getGridCellTitle(row, day)">
<span class="cell-hours">{{ getGridCellHours(row, day) }}</span>
</td>
<td class="total-cell">{{ row.total_hours }}h</td>
</tr>
</tbody>
</table>
<el-empty v-else description="暂无研发人员数据" />
</div>
<!-- 2. Individual Calendar View (个人日历) -->
<div v-else class="two-col-layout" style="margin-top: 16px;" v-loading="calendarLoading">
<!-- Calendar Card -->
<div class="table-col" style="flex: 1.5;">
<div class="content-card" style="margin-bottom: 0;">
<div class="card-title">
<span>📅 {{ selectedDeveloperName }} 的个人日历 ({{ calendarFilter.month }})</span>
<div class="calendar-legends">
<span class="legend-item"><span class="dot green"></span>已满8h</span>
<span class="legend-item"><span class="dot yellow"></span>未满8h</span>
<span class="legend-item"><span class="dot red"></span>漏填</span>
<span class="legend-item"><span class="dot gray"></span>周末/未到期</span>
</div>
</div>
<!-- Inline Calendar -->
<el-calendar v-model="adminCalendarDate" :key="adminCalendarKey">
<template #date-cell="{ data }">
<div class="day-cell-content" @click="handleAdminSelectDate(data.day)">
<div class="day-header">
<span class="day-num">{{ data.day.split('-')[2] }}</span>
<span v-if="data.type === 'current-month' && isRestDay(data.day)" class="weekend-tag"></span>
</div>
<div class="day-body" v-if="data.type === 'current-month'">
<div v-if="getAdminDayHours(data.day) >= 8" class="status-badge filled">
{{ getAdminDayHours(data.day) }}h
</div>
<div v-else-if="getAdminDayHours(data.day) > 0" class="status-badge partial">
{{ getAdminDayHours(data.day) }}h
</div>
<div v-else-if="isAdminPastUnfilled(data.day)" class="status-badge unfilled">
未填报
</div>
</div>
</div>
</template>
</el-calendar>
</div>
</div>
<!-- Side Details Card -->
<div class="chart-col" style="flex: 1; min-width: 320px;">
<!-- Month Summary Card -->
<div class="content-card">
<div class="card-title">📊 月度数据汇总</div>
<div class="mini-stats-grid">
<div class="mini-stat-item green-glow">
<div class="label">满额填报</div>
<div class="value green-text">{{ individualStats.fullDays }}<span class="unit"></span></div>
</div>
<div class="mini-stat-item yellow-glow">
<div class="label">部分填报</div>
<div class="value yellow-text">{{ individualStats.partialDays }}<span class="unit"></span></div>
</div>
<div class="mini-stat-item red-glow">
<div class="label">漏填工作日</div>
<div class="value red-text">{{ individualStats.unfilledDays }}<span class="unit"></span></div>
</div>
<div class="mini-stat-item purple-glow">
<div class="label">累计总工时</div>
<div class="value purple-text">{{ individualStats.totalHours }}<span class="unit">小时</span></div>
</div>
</div>
</div>
<!-- Date records card -->
<div class="content-card" style="margin-top: 16px;">
<div class="card-title">📋 {{ adminSelectedDate }} 工时明细</div>
<el-table :data="adminSelectedDateRecords" stripe style="width: 100%" size="small">
<el-table-column prop="client_name" label="客户/项目" min-width="130" />
<el-table-column prop="hours" label="工时" width="70" />
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
</el-table>
<el-empty v-if="!adminSelectedDateRecords.length" description="当天暂无工时记录" :image-size="50" />
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, watch, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi } from '../../api'
import * as echarts from 'echarts'
const activeTab = ref('sales')
const salesUsers = ref([])
const rdUsers = ref([])
const salesChartRef = ref(null)
const rdChartRef = ref(null)
const projectChartRef = ref(null)
const salesBarRef = ref(null)
const rdBarRef = ref(null)
const projectBarRef = ref(null)
let salesChartInstance = null
let rdChartInstance = null
let projectChartInstance = null
let salesBarInstance = null
let rdBarInstance = null
let projectBarInstance = null
const exportingAll = ref(false)
const getRecentMonthRange = () => {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 1)
const pad = (n) => String(n).padStart(2, '0')
const format = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return [format(start), format(end)]
}
const defaultRange = getRecentMonthRange()
const allExportDateRange = ref(defaultRange)
// ====== Sales ======
const salesFilter = reactive({ dateRange: defaultRange })
const salesRawData = ref([])
const salesDetailData = ref([])
const salesLoading = ref(false)
const salesSummary = computed(() => {
const map = {}
// Aggregate detail row by rd_id into the sales object
salesDetailData.value.forEach(row => {
if (!map[row.sales_id]) {
map[row.sales_id] = {
sales_id: row.sales_id,
sales_name: row.sales_name,
projects: new Set(),
rdMap: {},
total_hours: 0
}
}
const sales = map[row.sales_id]
if (!sales.rdMap[row.rd_id]) {
sales.rdMap[row.rd_id] = { rd_name: row.rd_name, total_hours: 0, work_days: 0 }
}
sales.rdMap[row.rd_id].total_hours += row.total_hours
sales.rdMap[row.rd_id].work_days += row.work_days
})
// Add project counts & total hours from raw overviews to base object
salesRawData.value.forEach(row => {
if (!map[row.sales_id]) {
map[row.sales_id] = {
sales_id: row.sales_id,
sales_name: row.sales_name,
projects: new Set(),
rdMap: {},
total_hours: 0
}
}
map[row.sales_id].projects.add(row.client_id)
map[row.sales_id].total_hours += row.total_hours
})
return Object.values(map).map(m => {
return {
sales_name: m.sales_name,
project_count: m.projects.size,
rd_count: Object.keys(m.rdMap).length,
total_hours: Math.round(m.total_hours * 10) / 10,
rdList: Object.values(m.rdMap).sort((a,b)=> b.total_hours - a.total_hours)
}
})
})
async function loadSalesData() {
salesLoading.value = true
try {
const params = {}
if (salesFilter.dateRange) {
params.start_date = salesFilter.dateRange[0]
params.end_date = salesFilter.dateRange[1]
}
const [overview, detail] = await Promise.all([
adminApi.getSalesStats(params),
adminApi.getSalesDetail(params)
])
salesRawData.value = overview.data
salesDetailData.value = detail.data
await nextTick()
const pieData = salesSummary.value.filter(s => s.total_hours > 0).map(s => ({name: s.sales_name, value: s.total_hours}))
renderPieChart(salesChartRef, salesChartInstance, 'sales', pieData)
const salesBarData = salesSummary.value.filter(s => s.total_hours > 0)
renderBarChart(salesBarRef, salesBarInstance, 'sales',
salesBarData.map(s => s.sales_name),
salesBarData.map(s => s.total_hours)
)
} finally { salesLoading.value = false }
}
// ====== R&D ======
const rdFilter = reactive({ dateRange: defaultRange, user_id: '' })
const rdData = ref([])
const rdLoading = ref(false)
const rdSummary = computed(() => {
const map = {}
rdData.value.forEach(row => {
if (!map[row.user_id]) {
map[row.user_id] = { name: row.user_name, value: 0 }
}
map[row.user_id].value += row.hours
})
return Object.values(map).filter(r => r.value > 0)
})
async function loadRdData() {
rdLoading.value = true
try {
const params = {}
if (rdFilter.dateRange) {
params.start_date = rdFilter.dateRange[0]
params.end_date = rdFilter.dateRange[1]
}
if (rdFilter.user_id) params.user_id = rdFilter.user_id
const res = await adminApi.getRdStats(params)
rdData.value = res.data
await nextTick()
renderPieChart(rdChartRef, rdChartInstance, 'rd', rdSummary.value)
renderBarChart(rdBarRef, rdBarInstance, 'rd',
rdSummary.value.map(r => r.name),
rdSummary.value.map(r => r.value)
)
} finally { rdLoading.value = false }
}
// ====== Project ======
const projFilter = reactive({ dateRange: defaultRange, sales_id: '' })
const projectData = ref([])
const projLoading = ref(false)
const projectDetailData = ref([])
async function loadProjectData() {
projLoading.value = true
try {
const params = {}
if (projFilter.dateRange) {
params.start_date = projFilter.dateRange[0]
params.end_date = projFilter.dateRange[1]
}
if (projFilter.sales_id) params.sales_id = projFilter.sales_id
const [overview, detail] = await Promise.all([
adminApi.getProjectStats(params),
adminApi.getProjectDetail(params)
])
// Group details by project
const detailMap = {}
detail.data.forEach(row => {
if (!detailMap[row.client_id]) detailMap[row.client_id] = []
detailMap[row.client_id].push(row)
})
// Attach rdList to each project
projectData.value = overview.data.map(p => {
p.rdList = detailMap[p.client_id] || []
return p
})
await nextTick()
const projPieData = projectData.value.filter(p => p.total_hours > 0).map(p => ({name: p.client_name, value: p.total_hours}))
renderPieChart(projectChartRef, projectChartInstance, 'project', projPieData)
const projBarData = projectData.value.filter(p => p.total_hours > 0)
renderBarChart(projectBarRef, projectBarInstance, 'project',
projBarData.map(p => p.client_name),
projBarData.map(p => p.total_hours)
)
} finally { projLoading.value = false }
}
// ====== Charts ======
function renderPieChart(domRef, instanceRef, type, dataArray) {
if (!domRef.value || domRef.value.clientWidth === 0) return
if (instanceRef) instanceRef.dispose()
let inst = echarts.init(domRef.value, 'dark')
if (type === 'sales') salesChartInstance = inst
if (type === 'rd') rdChartInstance = inst
if (type === 'project') projectChartInstance = inst
inst.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c}h ({d}%)', confine: true },
legend: { type: 'scroll', bottom: 10, textStyle: { color: '#94a3b8' } },
series: [{
type: 'pie', radius: ['35%', '65%'], center: ['50%', '45%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 8, borderColor: '#1e293b', borderWidth: 2 },
label: { color: '#f1f5f9', fontSize: 13 },
emphasis: {
label: { show: true, fontSize: 16, fontWeight: 'bold' },
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' }
},
data: dataArray
}]
})
}
function renderBarChart(domRef, instanceRef, type, categories, values) {
if (!domRef.value || domRef.value.clientWidth === 0) return
if (instanceRef) instanceRef.dispose()
let inst = echarts.init(domRef.value, 'dark')
if (type === 'sales') salesBarInstance = inst
if (type === 'rd') rdBarInstance = inst
if (type === 'project') projectBarInstance = inst
inst.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].name}: ${p[0].value}h`, confine: true },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: {
type: 'category', data: categories,
axisLabel: { color: '#94a3b8', interval: 0, rotate: categories.length > 5 ? 30 : 0, fontSize: 12 }
},
yAxis: { type: 'value', name: '工时(h)', axisLabel: { color: '#94a3b8' } },
series: [{
type: 'bar', barMaxWidth: 50, data: values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#818cf8' }, { offset: 1, color: '#4f46e5' }
]),
borderRadius: [4, 4, 0, 0]
},
label: { show: true, position: 'top', color: '#f1f5f9', fontSize: 12, formatter: '{c}h' },
markLine: type === 'rd' && values.length > 0 ? {
data: [{ type: 'average', name: '平均值' }],
lineStyle: { type: 'dashed', color: '#f59e0b', width: 2 },
label: { formatter: '平均: {c}h', position: 'end', color: '#f59e0b', fontWeight: 'bold' }
} : undefined
}]
})
}
function handleResize() {
salesChartInstance?.resize()
rdChartInstance?.resize()
projectChartInstance?.resize()
salesBarInstance?.resize()
rdBarInstance?.resize()
projectBarInstance?.resize()
}
// ====== Export ======
async function exportData(type) {
try {
if (type === 'all') {
exportingAll.value = true
let params = {}
let start = '开始', end = '至今'
if (allExportDateRange.value && allExportDateRange.value.length === 2) {
params.start_date = allExportDateRange.value[0]
params.end_date = allExportDateRange.value[1]
start = params.start_date
end = params.end_date
}
const response = await adminApi.exportAllExcel(params)
downloadBlob(response.data, `总报表_${start}${end}.xlsx`)
ElMessage.success('全导成功')
exportingAll.value = false
return
}
let params = {}
let start = '开始', end = '至今', prefix = ''
if (type === 'sales') {
prefix = '销售视角'
if (salesFilter.dateRange && salesFilter.dateRange.length === 2) {
params.start_date = salesFilter.dateRange[0]; params.end_date = salesFilter.dateRange[1];
start = params.start_date; end = params.end_date;
}
} else if (type === 'rd') {
prefix = '研发视角'
if (rdFilter.dateRange && rdFilter.dateRange.length === 2) {
params.start_date = rdFilter.dateRange[0]; params.end_date = rdFilter.dateRange[1];
start = params.start_date; end = params.end_date;
}
} else if (type === 'project') {
prefix = '项目视角'
if (projFilter.dateRange && projFilter.dateRange.length === 2) {
params.start_date = projFilter.dateRange[0]; params.end_date = projFilter.dateRange[1];
start = params.start_date; end = params.end_date;
}
if (projFilter.sales_id) params.sales_id = projFilter.sales_id;
}
const response = await adminApi.exportExcel(type, params)
downloadBlob(response.data, `${prefix}_${start}${end}.xlsx`)
ElMessage.success('导出成功')
} catch (e) {
exportingAll.value = false
ElMessage.error('导出失败')
}
}
function downloadBlob(data, filename) {
const url = URL.createObjectURL(new Blob([data]))
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
watch(activeTab, (tab) => {
nextTick(() => {
if (tab === 'sales') {
const filtered = salesSummary.value.filter(s => s.total_hours > 0)
renderPieChart(salesChartRef, salesChartInstance, 'sales', filtered.map(s => ({name: s.sales_name, value: s.total_hours})))
renderBarChart(salesBarRef, salesBarInstance, 'sales', filtered.map(s => s.sales_name), filtered.map(s => s.total_hours))
} else if (tab === 'rd') {
renderPieChart(rdChartRef, rdChartInstance, 'rd', rdSummary.value)
renderBarChart(rdBarRef, rdBarInstance, 'rd', rdSummary.value.map(r => r.name), rdSummary.value.map(r => r.value))
} else if (tab === 'project') {
const filtered = projectData.value.filter(p => p.total_hours > 0)
renderPieChart(projectChartRef, projectChartInstance, 'project', filtered.map(p => ({name: p.client_name, value: p.total_hours})))
renderBarChart(projectBarRef, projectBarInstance, 'project', filtered.map(p => p.client_name), filtered.map(p => p.total_hours))
} else if (tab === 'matrix') {
loadMatrixData()
} else if (tab === 'calendar') {
if (rdUsers.value.length && !calendarFilter.user_id) {
calendarFilter.user_id = rdUsers.value[0].id
}
loadCalendarData().then(() => {
if (calendarViewMode.value === 'individual') {
loadIndividualCalendarData()
}
})
}
})
})
onMounted(async () => {
window.addEventListener('resize', handleResize)
try {
const [s, r] = await Promise.all([adminApi.getSalesUsers(), adminApi.getRdUsers()])
salesUsers.value = s.data
rdUsers.value = r.data
if (rdUsers.value.length && !calendarFilter.user_id) {
calendarFilter.user_id = rdUsers.value[0].id
}
} catch (e) {}
loadSalesData()
loadRdData()
loadProjectData()
loadMatrixData()
loadCalendarData().then(() => {
if (calendarViewMode.value === 'individual') {
loadIndividualCalendarData()
}
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
salesChartInstance?.dispose()
rdChartInstance?.dispose()
projectChartInstance?.dispose()
salesBarInstance?.dispose()
rdBarInstance?.dispose()
projectBarInstance?.dispose()
})
// ====== Calendar (填报日历) ======
const calendarViewMode = ref('grid')
const calendarLoading = ref(false)
const todayStr = ref(new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }))
const calendarFilter = reactive({
month: new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).substring(0, 7),
user_id: ''
})
const adminCalendarDate = ref(new Date())
const adminSelectedDate = ref(new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }))
const calendarRawRecords = ref([])
const gridData = ref([])
const adminRecordsMap = ref({})
const adminCalendarKey = ref(0)
const selectedDeveloperName = computed(() => {
const found = rdUsers.value.find(u => u.id === calendarFilter.user_id)
return found ? found.realname : ''
})
const daysInSelectedMonth = computed(() => {
const [year, month] = calendarFilter.month.split('-').map(Number)
const daysCount = new Date(year, month, 0).getDate()
return Array.from({ length: daysCount }, (_, i) => i + 1)
})
const getDayLabel = (day) => {
const [year, month] = calendarFilter.month.split('-').map(Number)
const d = new Date(year, month - 1, day)
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
return weekdays[d.getDay()]
}
const getGridCellHours = (row, day) => {
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
const hours = row.dates[dStr]?.hours || 0
return hours > 0 ? hours : ''
}
const holidaysMap = ref({})
const loadedHolidayYear = ref(null)
async function loadHolidays(year) {
try {
const res = await adminApi.getHolidays(year)
holidaysMap.value = res.data || {}
adminCalendarKey.value++
} catch (e) {
console.error('Failed to load holidays:', e)
}
}
const getGridCellClass = (row, day) => {
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
const hours = row.dates[dStr]?.hours || 0
if (hours >= 8) {
return 'cell-filled'
} else if (hours > 0) {
return 'cell-partial'
} else {
if (dStr < todayStr.value && !isRestDay(dStr)) {
return 'cell-unfilled'
}
return 'cell-neutral'
}
}
const getGridCellTitle = (row, day) => {
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
const hours = row.dates[dStr]?.hours || 0
if (hours > 0) {
return `${row.user_name}${dStr} 已填报 ${hours}h`
} else {
if (dStr < todayStr.value && !isRestDay(dStr)) {
return `${row.user_name}${dStr} 漏填报`
}
return `${row.user_name}${dStr} 无需填报/未到期`
}
}
const getDayOfWeek = (dayStr) => {
const d = new Date(dayStr + 'T00:00:00')
return d.getDay()
}
const isWorkday = (dayStr) => {
const mmDd = dayStr.substring(5)
if (holidaysMap.value && holidaysMap.value[mmDd]) {
return !holidaysMap.value[mmDd].holiday
}
const day = getDayOfWeek(dayStr)
return day !== 0 && day !== 6
}
const isRestDay = (dayStr) => {
return !isWorkday(dayStr)
}
const getAdminDayHours = (dayStr) => {
return adminRecordsMap.value[dayStr]?.hours || 0
}
const isAdminPastUnfilled = (dayStr) => {
if (dayStr >= todayStr.value) return false
if (isRestDay(dayStr)) return false
return getAdminDayHours(dayStr) === 0
}
const adminSelectedDateRecords = computed(() => {
return adminRecordsMap.value[adminSelectedDate.value]?.list || []
})
async function loadCalendarData() {
calendarLoading.value = true
try {
const [yearStr, monthStr] = calendarFilter.month.split('-')
const year = parseInt(yearStr)
const month = parseInt(monthStr)
const daysCount = new Date(year, month, 0).getDate()
const start_date = `${calendarFilter.month}-01`
const end_date = `${calendarFilter.month}-${String(daysCount).padStart(2, '0')}`
// Fetch holidays if year changes
if (loadedHolidayYear.value !== year) {
loadedHolidayYear.value = year
await loadHolidays(year)
}
const res = await adminApi.getRdStats({ start_date, end_date })
calendarRawRecords.value = res.data || []
buildGridData()
} catch (e) {
ElMessage.error('获取填报日历数据失败')
} finally {
calendarLoading.value = false
}
}
function buildGridData() {
const userMap = {}
rdUsers.value.forEach(u => {
userMap[u.id] = {
user_id: u.id,
user_name: u.realname,
dates: {},
total_hours: 0
}
})
calendarRawRecords.value.forEach(r => {
const uid = r.user_id
if (!userMap[uid]) {
userMap[uid] = {
user_id: uid,
user_name: r.user_name || '未知研发',
dates: {},
total_hours: 0
}
}
const dateStr = r.work_date
if (!userMap[uid].dates[dateStr]) {
userMap[uid].dates[dateStr] = { hours: 0, records: [] }
}
userMap[uid].dates[dateStr].hours += r.hours
userMap[uid].dates[dateStr].records.push(r)
userMap[uid].total_hours += r.hours
})
gridData.value = Object.values(userMap)
.map(u => {
u.total_hours = Math.round(u.total_hours * 10) / 10
return u
})
.filter(u => u.total_hours > 0)
}
function handleCalendarFilterChange() {
loadCalendarData().then(() => {
if (calendarViewMode.value === 'individual') {
loadIndividualCalendarData()
}
})
}
function switchToIndividual(userId) {
calendarFilter.user_id = userId
calendarViewMode.value = 'individual'
loadIndividualCalendarData()
}
function loadIndividualCalendarData() {
if (!calendarFilter.user_id) return
const map = {}
calendarRawRecords.value.forEach(r => {
if (r.user_id === calendarFilter.user_id) {
if (!map[r.work_date]) {
map[r.work_date] = { hours: 0, list: [] }
}
map[r.work_date].hours += r.hours
map[r.work_date].list.push(r)
}
})
adminRecordsMap.value = map
adminCalendarKey.value++
const [cy, cm] = calendarFilter.month.split('-').map(Number)
const currentCalDate = adminCalendarDate.value
if (currentCalDate.getFullYear() !== cy || (currentCalDate.getMonth() + 1) !== cm) {
adminCalendarDate.value = new Date(cy, cm - 1, 1)
}
updateAdminSelectedDate()
}
function handleAdminSelectDate(dayStr) {
adminSelectedDate.value = dayStr
}
function updateAdminSelectedDate() {
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' })
if (today.startsWith(calendarFilter.month)) {
adminSelectedDate.value = today
} else {
adminSelectedDate.value = `${calendarFilter.month}-01`
}
}
watch(adminCalendarDate, (newDate) => {
if (newDate && calendarViewMode.value === 'individual') {
const year = newDate.getFullYear()
const month = String(newDate.getMonth() + 1).padStart(2, '0')
const yyyyMm = `${year}-${month}`
if (calendarFilter.month !== yyyyMm) {
calendarFilter.month = yyyyMm
loadCalendarData().then(() => {
loadIndividualCalendarData()
})
}
}
})
watch(rdUsers, () => {
buildGridData()
})
watch(calendarViewMode, (newMode) => {
if (newMode === 'individual') {
if (!calendarFilter.user_id && rdUsers.value.length) {
calendarFilter.user_id = rdUsers.value[0].id
}
loadIndividualCalendarData()
}
})
const individualStats = computed(() => {
const [year, month] = calendarFilter.month.split('-').map(Number)
const daysCount = new Date(year, month, 0).getDate()
let fullDays = 0
let partialDays = 0
let unfilledDays = 0
let totalHours = 0
for (let day = 1; day <= daysCount; day++) {
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
const hours = getAdminDayHours(dStr)
totalHours += hours
if (hours >= 8) {
fullDays++
} else if (hours > 0) {
partialDays++
} else {
if (dStr < todayStr.value && !isRestDay(dStr)) {
unfilledDays++
}
}
}
return {
fullDays,
partialDays,
unfilledDays,
totalHours: Math.round(totalHours * 10) / 10
}
})
// ====== Matrix (研发工时汇总) ======
const matrixFilter = reactive({ dateRange: defaultRange })
const matrixLoading = ref(false)
const matrixCols = ref([]) // 项目-子项目 labels
const matrixRows = ref([]) // { label, cells:{colName:hours}, total }
async function loadMatrixData() {
matrixLoading.value = true
try {
const params = {}
if (matrixFilter.dateRange && matrixFilter.dateRange.length === 2) {
params.start_date = matrixFilter.dateRange[0]
params.end_date = matrixFilter.dateRange[1]
}
const res = await adminApi.getSummaryMatrix(params)
const raw = res.data
// Build cols = unique 项目-子项目 labels
const colSet = new Set(raw.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))
matrixCols.value = [...colSet].sort()
// Build row keys = 研发人员
const rowKeyMap = {}
raw.forEach(r => {
const ck = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name
if (!rowKeyMap[r.user_name]) rowKeyMap[r.user_name] = {}
rowKeyMap[r.user_name][ck] = (rowKeyMap[r.user_name][ck] || 0) + r.hours
})
const dataRows = Object.entries(rowKeyMap).map(([label, cells]) => {
const total = Object.values(cells).reduce((s, v) => s + v, 0)
return { label, cells, total: Math.round(total * 10) / 10 }
})
// Totals row
const totalCells = {}
matrixCols.value.forEach(c => {
totalCells[c] = Math.round(dataRows.reduce((s, r) => s + (r.cells[c] || 0), 0) * 10) / 10
})
const grandTotal = Math.round(dataRows.reduce((s, r) => s + r.total, 0) * 10) / 10
dataRows.push({ label: '合计', cells: totalCells, total: grandTotal })
matrixRows.value = dataRows
} finally { matrixLoading.value = false }
}
async function exportMatrix() {
const params = {}
let start = '开始', end = '至今'
if (matrixFilter.dateRange && matrixFilter.dateRange.length === 2) {
params.start_date = matrixFilter.dateRange[0]
params.end_date = matrixFilter.dateRange[1]
start = params.start_date; end = params.end_date
}
const response = await adminApi.exportMatrixExcel(params)
downloadBlob(response.data, `研发工时汇总_${start}${end}.xlsx`)
ElMessage.success('导出成功')
}
</script>
<style scoped>
/* Tabs Overrides */
.dashboard-page :deep(.el-tabs--border-card) {
background: var(--bg-card) !important;
border-color: var(--border-color) !important;
border-radius: var(--radius) !important;
overflow: visible !important;
}
.dashboard-page :deep(.el-tabs__content) {
overflow: visible !important;
padding: 20px !important;
}
.dashboard-page :deep(.el-tab-pane) {
overflow: visible !important;
}
.dashboard-page :deep(.el-tabs__header) {
background: rgba(51, 65, 85, 0.3) !important;
border-bottom-color: var(--border-color) !important;
}
.dashboard-page :deep(.el-tabs__item) { color: var(--text-secondary) !important; }
.dashboard-page :deep(.el-tabs__item.is-active) {
color: var(--primary-light) !important;
background: var(--bg-card) !important;
}
.dashboard-page :deep(.el-tabs__item:hover) { color: var(--text-primary) !important; }
/* Table Overrides for Expandable Rows */
.dashboard-page :deep(.el-table__expanded-cell) {
background-color: rgba(0,0,0,0.1) !important;
padding: 0 !important;
}
.dashboard-page :deep(.el-table__expanded-cell:hover) {
background-color: rgba(0,0,0,0.1) !important;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.two-col-layout {
display: flex;
gap: 24px;
align-items: flex-start;
margin-top: 16px;
}
.table-col {
flex: 2;
min-width: 0;
}
.chart-col {
flex: 1;
min-width: 350px;
}
.chart-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 20px;
}
.chart-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
text-align: center;
}
.big-chart {
width: 100%;
height: 400px;
}
/* Matrix Table */
.matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.matrix-table th, .matrix-table td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: center;
white-space: nowrap;
}
.matrix-table thead th {
background: rgba(99, 102, 241, 0.15);
color: var(--primary-light);
font-weight: 600;
}
.matrix-table .row-header {
text-align: left;
font-weight: 500;
background: rgba(255,255,255,0.03);
min-width: 160px;
}
.matrix-table .total-col {
font-weight: 700;
color: #818cf8;
background: rgba(99,102,241,0.08);
}
.matrix-table .total-row td {
font-weight: 700;
background: rgba(99,102,241,0.12);
color: #c7d2fe;
}
.matrix-table tbody tr:hover td {
background: rgba(255,255,255,0.04);
}
/* Calendar tab custom styles */
.calendar-legends {
display: flex;
gap: 12px;
font-size: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
}
.legend-item .dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-item .dot.green { background: var(--success); }
.legend-item .dot.yellow { background: var(--warning); }
.legend-item .dot.red { background: var(--danger); }
.legend-item .dot.gray { background: var(--text-muted); }
/* All staff grid table */
.grid-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.grid-table th, .grid-table td {
border: 1px solid var(--border-color);
padding: 6px 4px;
text-align: center;
vertical-align: middle;
}
.grid-table thead th {
background: rgba(51, 65, 85, 0.5);
color: var(--text-secondary);
font-weight: 600;
padding: 8px 4px;
}
.grid-table .name-header {
text-align: left;
font-weight: 600;
color: var(--text-primary);
min-width: 100px;
padding-left: 12px;
background: var(--bg-card);
position: sticky;
left: 0;
z-index: 5;
border-right: 2px solid var(--border-color);
}
.grid-table .name-header.clickable {
cursor: pointer;
transition: var(--transition);
}
.grid-table .name-header.clickable:hover {
background: var(--bg-hover);
color: var(--primary-light);
}
.arrow-tip {
font-size: 10px;
opacity: 0;
transition: var(--transition);
margin-left: 4px;
}
.grid-table tr:hover .arrow-tip {
opacity: 0.8;
}
.day-col {
min-width: 32px;
font-size: 11px;
}
.day-weekday {
display: block;
font-size: 9px;
color: var(--text-muted);
font-weight: normal;
margin-top: 2px;
}
.status-cell {
position: relative;
transition: var(--transition);
}
.cell-hours {
font-weight: 600;
font-size: 11px;
}
.status-cell.cell-filled {
background-color: rgba(16, 185, 129, 0.2) !important;
color: #34d399;
}
.status-cell.cell-partial {
background-color: rgba(245, 158, 11, 0.2) !important;
color: #fbbf24;
}
.status-cell.cell-unfilled {
background-color: rgba(239, 68, 68, 0.2) !important;
color: #f87171;
}
.status-cell.cell-neutral {
background-color: transparent;
color: var(--text-muted);
}
.total-header, .total-cell {
font-weight: 700;
min-width: 70px;
background: rgba(99, 102, 241, 0.05);
}
.total-cell {
color: var(--primary-light);
}
/* Individual Calendar UI overrides */
:deep(.el-calendar) {
--el-calendar-border-color: var(--border-color);
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius);
}
:deep(.el-calendar__header) {
border-bottom: 1px solid var(--border-color);
padding: 12px 16px;
}
:deep(.el-calendar-table td) {
border-bottom: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
background-color: transparent !important;
}
:deep(.el-calendar-table .el-calendar-day) {
height: 60px;
padding: 4px;
transition: var(--transition);
}
:deep(.el-calendar-table .el-calendar-day:hover) {
background-color: var(--bg-hover) !important;
}
:deep(.el-calendar-table td.is-selected) {
background-color: rgba(99, 102, 241, 0.15) !important;
}
.day-cell-content {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.day-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.day-num {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.weekend-tag {
font-size: 9px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0 3px;
border-radius: 2px;
}
.day-body {
margin-top: 2px;
display: flex;
justify-content: center;
}
.status-badge {
font-size: 9px;
padding: 0px 4px;
border-radius: 3px;
font-weight: 500;
text-align: center;
width: 90%;
}
.status-badge.filled {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status-badge.partial {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.status-badge.unfilled {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* Stats mini grid for Admin calendar */
.mini-stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.mini-stat-item {
background: rgba(30, 41, 59, 0.4);
border: 1px solid var(--border-color);
padding: 12px;
border-radius: var(--radius-sm);
display: flex;
flex-direction: column;
gap: 4px;
transition: var(--transition);
}
.mini-stat-item:hover {
transform: translateY(-1px);
}
.green-glow:hover { border-color: rgba(16, 185, 129, 0.4); }
.yellow-glow:hover { border-color: rgba(245, 158, 11, 0.4); }
.red-glow:hover { border-color: rgba(239, 68, 68, 0.4); }
.purple-glow:hover { border-color: rgba(99, 102, 241, 0.4); }
.mini-stat-item .label {
font-size: 11px;
color: var(--text-muted);
}
.mini-stat-item .value {
font-size: 18px;
font-weight: 700;
}
.mini-stat-item .unit {
font-size: 10px;
color: var(--text-secondary);
}
.green-text { color: #10b981; }
.yellow-text { color: #f59e0b; }
.red-text { color: #ef4444; }
.purple-text { color: #818cf8; }
</style>