Files
vue-tickets/src/views/StatsView.vue

409 lines
11 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="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>