first
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
<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>
|
||||
|
||||
</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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
} catch (e) {}
|
||||
loadSalesData()
|
||||
loadRdData()
|
||||
loadProjectData()
|
||||
loadMatrixData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
salesChartInstance?.dispose()
|
||||
rdChartInstance?.dispose()
|
||||
projectChartInstance?.dispose()
|
||||
salesBarInstance?.dispose()
|
||||
rdBarInstance?.dispose()
|
||||
projectBarInstance?.dispose()
|
||||
})
|
||||
|
||||
// ====== 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);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user