409 lines
11 KiB
Vue
409 lines
11 KiB
Vue
<template>
|
||
<div class="stats-view">
|
||
<el-card class="box-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>员工锁定站点统计</span>
|
||
<div class="header-actions">
|
||
<el-text size="small" type="info" style="margin-right: 12px">只看本月</el-text>
|
||
<el-switch v-model="showOnlyThisMonth" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-loading="loading">
|
||
<el-empty v-if="!staffWithLockedTickets.length && !loading" description="暂无已锁定的站点数据" />
|
||
<el-collapse v-else v-model="activeCollapse" accordion>
|
||
<el-collapse-item v-for="staff in staffWithLockedTickets" :key="staff.id" :name="staff.id">
|
||
<template #title>
|
||
<div class="collapse-title" @mousedown.prevent>
|
||
<div class="staff-info">
|
||
<el-avatar :size="28">{{ staff.username.slice(0, 1) }}</el-avatar>
|
||
<span class="staff-name">{{ staff.username }}</span>
|
||
</div>
|
||
<span class="count-badge">
|
||
已锁定 {{ staff.lockedTickets.length }} 个站点
|
||
</span>
|
||
</div>
|
||
</template>
|
||
<el-table :data="staff.lockedTickets" stripe border size="small" class="inner-table">
|
||
<el-table-column prop="id" label="工单ID" width="80" align="center" />
|
||
<el-table-column prop="website" label="站点名称" min-width="200" show-overflow-tooltip />
|
||
<el-table-column prop="operator_name" label="运营" width="120" align="center" />
|
||
<el-table-column prop="datanum" label="数据量" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<span>{{ row.datanum && row.datanum !== "0" ? row.datanum : "-" }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="锁定日期" width="150" align="center">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.lock_time) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<span class="status-badge" :class="'s' + Number(row.status)">
|
||
{{ statusLabel(row.status) }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
<!-- 数据记录导出 -->
|
||
<div class="export-bar">
|
||
<span class="export-label">数据记录导出</span>
|
||
<div class="export-actions">
|
||
<el-radio-group v-model="exportRange" size="small">
|
||
<el-radio value="week">本周</el-radio>
|
||
<el-radio value="month">本月</el-radio>
|
||
</el-radio-group>
|
||
<el-button size="small" @click="handleExport">导出</el-button>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, computed } from "vue";
|
||
import { ticketApi, exportApi } from "@/api/ticket";
|
||
import { ElMessage } from "element-plus";
|
||
import { formatDate } from "@/utils/format";
|
||
import * as XLSX from "xlsx";
|
||
|
||
const loading = ref(true);
|
||
const staffUserList = ref([]);
|
||
const allTickets = ref([]);
|
||
const activeCollapse = ref("");
|
||
const showOnlyThisMonth = ref(false);
|
||
const exportRange = ref("week");
|
||
|
||
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
||
const statusLabel = (status) => statusMap[Number(status)] || "未知";
|
||
|
||
const handleExport = () => {
|
||
// 使用接口获取需要导出的数据
|
||
exportApi.exportdata(exportRange.value).then((res) => {
|
||
|
||
if (res.code === 200) {
|
||
const data = res.data || [];
|
||
exportToExcel(data, exportRange.value === "week" ? "本周" : "本月", exportRange.value);
|
||
} else {
|
||
ElMessage.error("导出失败,请稍后再试");
|
||
}
|
||
}).catch(() => {
|
||
ElMessage.error("导出失败,请稍后再试");
|
||
});
|
||
|
||
};
|
||
|
||
const exportToExcel = (data, label, range) => {
|
||
data.sort((a, b) => {
|
||
const timeA = new Date(a.create_time).getTime();
|
||
const timeB = new Date(b.create_time).getTime();
|
||
if (timeA !== timeB) return timeA - timeB;
|
||
return a.username.localeCompare(b.username, "zh-CN");
|
||
});
|
||
|
||
const excelData = data.map((item) => ({
|
||
域名: item.website,
|
||
数据量: item.datanum || 0,
|
||
运营人员: item.operator_name,
|
||
采集人员: item.username,
|
||
备注: item.remark,
|
||
开始时间: item.create_time,
|
||
结束时间: item.lock_time,
|
||
}));
|
||
|
||
// 生成工作表
|
||
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||
|
||
// 自动调整列宽
|
||
const objectKeys = Object.keys(excelData[0] || {});
|
||
const max_widths = objectKeys.map((key) => {
|
||
let maxLen = key.replace(/[^\x00-\xff]/g, "00").length;
|
||
excelData.forEach((row) => {
|
||
const val = row[key] != null ? String(row[key]) : "";
|
||
const valLen = val.replace(/[^\x00-\xff]/g, "00").length;
|
||
if (valLen > maxLen) maxLen = valLen;
|
||
});
|
||
return { wch: maxLen + 3 };
|
||
});
|
||
worksheet["!cols"] = max_widths;
|
||
|
||
// 创建工作簿并导出
|
||
const workbook = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(workbook, worksheet, "工单导出");
|
||
const now = new Date();
|
||
const month = now.getMonth() + 1;
|
||
const weekOfMonth = Math.ceil(now.getDate() / 7);
|
||
if (range === "week") {
|
||
XLSX.writeFile(workbook, `工单数据导出_${month}月第${weekOfMonth}周.xlsx`);
|
||
} else if (range === "month") {
|
||
XLSX.writeFile(workbook, `工单数据导出_${month}月.xlsx`);
|
||
}
|
||
ElMessage.success(`已导出${label}记录`);
|
||
}
|
||
|
||
|
||
onMounted(async () => {
|
||
loading.value = true;
|
||
try {
|
||
const [staffRes, ticketsRes] = await Promise.all([
|
||
ticketApi.getUserStaffs(),
|
||
ticketApi.getTickets({ page: 1, limit: 9999 }),
|
||
]);
|
||
|
||
if (staffRes.code === 200) {
|
||
staffUserList.value = staffRes.data;
|
||
} else {
|
||
ElMessage.error("获取员工列表失败");
|
||
}
|
||
|
||
if (ticketsRes.code) {
|
||
allTickets.value = ticketsRes.data.data;
|
||
} else {
|
||
ElMessage.error("获取工单列表失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("加载数据失败:", error);
|
||
ElMessage.error("加载数据失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
});
|
||
|
||
const staffWithLockedTickets = computed(() => {
|
||
if (!staffUserList.value.length || !allTickets.value.length) return [];
|
||
|
||
// 筛选已锁定工单(状态为已完成或已完结,且有 lock_time)
|
||
let lockedTickets = allTickets.value.filter((ticket) => {
|
||
const status = Number(ticket.status);
|
||
const isLockedStatus = status === 2 || status === 3;
|
||
return isLockedStatus && ticket.lock_time && ticket.username;
|
||
});
|
||
|
||
if (showOnlyThisMonth.value) {
|
||
const now = new Date();
|
||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||
|
||
lockedTickets = lockedTickets.filter((ticket) => {
|
||
const lockDate = new Date(ticket.lock_time);
|
||
return !isNaN(lockDate.getTime()) && lockDate >= startOfMonth && lockDate <= endOfMonth;
|
||
});
|
||
}
|
||
|
||
// 按员工分配工单:后端返回 username 字符串,通过 name 匹配员工
|
||
const staffMap = new Map(
|
||
staffUserList.value.map((s) => [s.username, { ...s, lockedTickets: [] }])
|
||
);
|
||
|
||
lockedTickets.forEach((ticket) => {
|
||
const staff = staffMap.get(ticket.username);
|
||
if (staff) {
|
||
staff.lockedTickets.push(ticket);
|
||
}
|
||
});
|
||
|
||
// 每个员工内按锁定时间倒序
|
||
staffMap.forEach((staff) => {
|
||
staff.lockedTickets.sort((a, b) =>
|
||
new Date(b.lock_time).getTime() - new Date(a.lock_time).getTime()
|
||
);
|
||
});
|
||
|
||
return Array.from(staffMap.values())
|
||
.filter((s) => s.lockedTickets.length > 0)
|
||
.sort((a, b) => b.lockedTickets.length - a.lockedTickets.length);
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.stats-view {
|
||
padding: 0;
|
||
}
|
||
|
||
:deep(.el-card) {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border-light);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
:deep(.el-card__header) {
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
padding: 18px 22px;
|
||
background: var(--color-surface-soft);
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.card-header>span {
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
color: #1c1c28;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.collapse-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding-right: 16px;
|
||
}
|
||
|
||
.staff-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.staff-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1c1c28;
|
||
}
|
||
|
||
.count-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 12px;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #15803d;
|
||
background: #dcf5e6;
|
||
border: 1px solid rgba(22, 163, 74, 0.22);
|
||
white-space: nowrap;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.count-badge::before {
|
||
content: "";
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: #22c55e;
|
||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||
}
|
||
|
||
.inner-table {
|
||
width: calc(100% - 32px);
|
||
margin: 0 16px 16px 16px;
|
||
}
|
||
|
||
:deep(.inner-table) {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.el-collapse) {
|
||
border: none;
|
||
}
|
||
|
||
:deep(.el-collapse-item__header) {
|
||
padding: 0 16px;
|
||
height: 52px;
|
||
background: transparent;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||
color: #1c1c28;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
}
|
||
|
||
:deep(.el-collapse-item__header:hover) {
|
||
background: rgba(0, 0, 0, 0.015);
|
||
}
|
||
|
||
:deep(.el-collapse-item__wrap) {
|
||
background: none;
|
||
border: none;
|
||
}
|
||
|
||
:deep(.el-collapse-item__content) {
|
||
padding-bottom: 0;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
:deep(.el-table) {
|
||
--el-table-bg-color: #fafbfc;
|
||
--el-table-header-bg-color: #f0f1f4;
|
||
--el-table-border-color: rgba(0, 0, 0, 0.04);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.el-table th) {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #6b6d7a;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
/* 状态徽章 */
|
||
.status-badge {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.s0 {
|
||
color: #64748b;
|
||
background: #eef1f6;
|
||
}
|
||
|
||
.s1 {
|
||
color: #4f46e5;
|
||
background: #e7e9ff;
|
||
}
|
||
|
||
.s2 {
|
||
color: #16a34a;
|
||
background: #dcf5e6;
|
||
}
|
||
|
||
.s3 {
|
||
color: #d97706;
|
||
background: #ffefd2;
|
||
}
|
||
|
||
/* 导出栏 */
|
||
.export-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 20px;
|
||
padding: 14px 18px;
|
||
background: var(--color-surface-soft);
|
||
border: 1px solid var(--color-border-light);
|
||
border-left: 4px solid var(--color-primary);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.export-label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #1c1c28;
|
||
}
|
||
|
||
.export-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
</style>
|