Commit b2c56724 by 李宁

1

1 parent 110a8295
地市工单统计模块修改
有四个图表,不要混到一个图表里
工单数图表:x轴数据(工单总数、完成工单数、未完成工单数)
质检工单数图表:x轴数据(质检工单总数、完成质检工单数、未完成质检工单数)
投诉工单数图表:x轴数据(投诉工单总数、完成投诉工单数、未完成投诉工单数)
无法质检工单数:x轴数据(13个地市)
1、地市工单统计模块修改
一行展示一个图表,不然太拥挤了,采用顶部tab切换
2、耗时统计模块修改
一行展示一个图表,不然太拥挤了,采用顶部tab切换
1、质检环节统计
有三个图表,采用顶部tab切换(平均识别次数、识别总次数、平均耗时)
每个图表上方再添加一个“显示数量”的选择框,有3个选项,分别是10、25、全部,默认10
设备类型(工服、工牌、账号、环境、普通光猫、普通光猫串号、主光猫、主光猫串号、子光猫、子光猫串号、机顶盒、机顶盒串号 、路由器、路由器串号、电视画面 、电视软终端串号、云电脑终端、云电脑终端串号、云电脑、室内摄像头、室内摄像头串号、室外摄像头、故障投诉光猫灯、故障投诉主光猫灯)
地市工单统计/耗时统计/质检环节统计,三个模块的标题右侧添加“查看详细数据”
点击可以显示/隐藏表格数据,表格数据的位置和图表调换下,放到上面
设备识别数据页面
表格中,除了“设备名称”,其他的几列都要可设置高低排序
遍历整个项目,将所用到的“日期筛选”和“分页查询”都改成中文显示
串号数据统计页面修改
1、识别设备串号有:普通光猫串号、机顶盒串号、路由器串号、从光猫串号、主光猫串号、室内安防串号、软终端串号、云电脑终端串号、POE交换机串号,页面图表上显示的少了
2、当筛选日期大于一天,页面需要增加显示两个图表,分别是:整体通过率时间趋势、整体自动识别率时间趋势,x轴数据为日期,y轴数据为通过率和自动识别率
3、当显示整体时间趋势的图表时,图表上方添加tab切换“整体趋势”和“设备详情”,默认显示“整体趋势”,点击“设备详情”时,显示设备详情的表格数据
串号数据统计页面修改
1、不管那个tab下,都采用一行展示一个图表,不然太拥挤了
2、“整体趋势”模块的x轴数据为筛选条件中的日期,整体通过率时间趋势表中,y轴数据为1次通过率、2次通过率、3次通过率
3、“设备详情”模块,少了“设备自动识别率”图表,x轴数据为设备名称,y轴数据为自动识别率
串号数据统计页面修改
1、筛选条件为一天时:
1.1 “自动识别通过率”图表,x轴数据为设备名称(目前不够全)
1.2 “识别通过率”图表,显示样式有问题,x轴数据和底部的注释重合了
2、筛选条件多天时:
2.1 “整体通过率时间趋势”图表,显示样式有问题,x轴数据和底部的注释重合了
2.2 “设备详情”tab下,少了“设备自动识别率”图表,x轴数据为设备名称,y轴数据为自动识别率
串号数据统计页面修改
筛选条件多天时,“设备详情”tab下也是有两个图表的,“设备通过率”和“设备自动识别率”,x轴数据为设备名称,y轴数据为通过率和自动识别率
无法拍摄数据页面修改
1、图表改成堆叠柱状图方式呈现,x轴数据为13个地市,y轴为数据为占比
2、筛选条件中:地区筛选的列表,补全为13个地市
无法拍摄数据页面修改
1、图表展现形式还是不对,按照“串号数据统计”页面中“设备识别通过率”的图表展现形式,改成堆叠柱状图方式呈现,x轴数据为13个地市,y轴为数据为占比
\ No newline at end of file \ No newline at end of file
No preview for this file type
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "web-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.1",
"pinia": "^3.0.4",
"sass": "^1.97.2",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file \ No newline at end of file
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<el-config-provider :locale="zhCn">
<RouterView />
</el-config-provider>
</template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
\ No newline at end of file \ No newline at end of file
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { Menu as IconMenu, DataLine, Document } from '@element-plus/icons-vue'
const route = useRoute()
</script>
<template>
<el-container class="layout-container">
<el-aside width="220px" class="bg-[#001529] text-white min-h-screen flex flex-col transition-all duration-300">
<div class="h-16 flex items-center justify-center font-bold text-lg border-b border-gray-700 bg-[#002140]">
质检管理平台
</div>
<el-menu
active-text-color="#409eff"
background-color="#001529"
class="el-menu-vertical-demo border-none flex-1"
text-color="#fff"
:default-active="route.path"
router
>
<el-sub-menu index="/order">
<template #title>
<el-icon><Document /></el-icon>
<span>质检工单管理</span>
</template>
<el-menu-item index="/order-list">质检工单列表</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/stats">
<template #title>
<el-icon><DataLine /></el-icon>
<span>数据统计</span>
</template>
<el-menu-item index="/stats/quality">质检工单数据</el-menu-item>
<el-menu-item index="/stats/device">设备识别数据</el-menu-item>
<el-menu-item index="/stats/serial">串号数据统计</el-menu-item>
<el-menu-item index="/stats/no-photo">无法拍摄数据</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="bg-white border-b shadow-sm flex items-center justify-between px-6 h-16">
<div class="text-gray-500 text-sm">首页 / {{ route.name || 'Dashboard' }}</div>
<div class="flex items-center gap-4">
<el-dropdown>
<span class="el-dropdown-link flex items-center gap-2 cursor-pointer">
<el-avatar size="small" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
<span>Admin</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="bg-[#f0f2f5] p-6 h-[calc(100vh-64px)] overflow-y-auto">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.layout-container {
height: 100vh;
overflow: hidden;
}
:deep(.el-menu) {
border-right: none;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
import type { Order } from '../types/order'
const CITIES = ['南京市', '无锡市', '徐州市', '常州市', '苏州市', '南通市', '连云港市', '淮安市', '盐城市', '扬州市', '镇江市', '泰州市', '宿迁市']
const STATUSES = ['未开始', '进行中', '已完成', '无法质检'] as const
const CHEATING_REASONS = ['在家质检', '一直点击无法质检', '其他']
export const generateMockOrders = (count: number = 20): Order[] => {
return Array.from({ length: count }).map((_, index) => {
const isCheating = Math.random() > 0.8
const status = STATUSES[Math.floor(Math.random() * STATUSES.length)] || '未开始'
const city = CITIES[Math.floor(Math.random() * CITIES.length)] || '南京市'
return {
id: `APPLY_${Date.now()}_${index}`,
applyId: `APP${10000 + index}`,
workerId: `W${2000 + index}`,
businessAccount: `1${Math.floor(Math.random() * 10000000000)}`,
orderIds: [`ORD_${index}_1`, `ORD_${index}_2`],
city: city,
status: status,
cannotQcReason: status === '无法质检' ? '用户拒绝配合' : undefined,
startTime: '2023-10-01 10:00:00',
endTime: status === '已完成' ? '2023-10-01 11:30:00' : undefined,
noPhotoCount: Math.floor(Math.random() * 5),
manualInputCount: Math.floor(Math.random() * 3),
envAbnormalCount: Math.floor(Math.random() * 2),
isCheating: isCheating,
cheatingReason: isCheating ? CHEATING_REASONS[0] : undefined,
cheatingTime: isCheating ? '2023-10-01 12:00:00' : undefined
}
})
}
import type { OrderDetail, QcStep, QcVideo } from '../types/order'
export const getMockOrderDetail = (id: string): OrderDetail => {
const steps: QcStep[] = [
{ name: '工服检测', duration: '5s', result: '通过', imageUrl: 'https://placehold.co/100x100?text=Uniform', isAbnormal: 0 },
{ name: '光猫识别', duration: '12s', result: '通过', imageUrl: 'https://placehold.co/100x100?text=Modem', isAbnormal: 0 },
{ name: '机顶盒识别', duration: '8s', result: '通过', imageUrl: 'https://placehold.co/100x100?text=STB', isAbnormal: 0 },
{ name: '环境检测', duration: '15s', result: '通过', imageUrl: 'https://placehold.co/100x100?text=Env', isAbnormal: 1 }
]
const videos: QcVideo[] = [
{ id: 'v1', name: '全流程录像.mp4', thumbnailUrl: 'https://placehold.co/300x200?text=Video1', videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4' },
{ id: 'v2', name: '异常复核录像.mp4', thumbnailUrl: 'https://placehold.co/300x200?text=Video2', videoUrl: 'https://www.w3schools.com/html/movie.mp4' }
]
// Base random data
const baseOrder = generateMockOrders(1)[0]
if (!baseOrder) throw new Error('Failed to generate mock order')
return {
...baseOrder,
id: id,
installAddress: '江苏省南京市雨花台区软件大道101号',
orderType: '装机工单',
deviceType: 'FTTR全光组网',
completeTime: baseOrder.status === '已完成' ? '2023-10-01 11:30:00' : undefined,
totalDuration: '15分30秒',
steps: steps,
videos: videos
}
}
import { createRouter, createWebHistory } from 'vue-router'
import DefaultLayout from '../layouts/DefaultLayout.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: DefaultLayout,
redirect: '/order-list',
children: [
{
path: '/order-list',
name: '质检工单列表',
component: () => import('../views/OrderList.vue')
},
{
path: '/order-detail/:id',
name: '质检工单详情',
component: () => import('../views/OrderDetail.vue'),
meta: { hideInMenu: true }
},
{
path: '/stats/quality',
name: '质检工单数据',
component: () => import('../views/stats/QualityStats.vue')
},
{
path: '/stats/device',
name: '设备识别数据',
component: () => import('../views/stats/DeviceStats.vue')
},
{
path: '/stats/serial',
name: '串号数据统计',
component: () => import('../views/stats/SerialStats.vue')
},
{
path: '/stats/no-photo',
name: '无法拍摄数据',
component: () => import('../views/stats/NoPhotoStats.vue')
}
]
}
]
})
export default router
@import "tailwindcss";
\ No newline at end of file \ No newline at end of file
export interface Order {
id: string;
applyId: string;
workerId: string;
businessAccount: string;
orderIds: string[]; // List of Order IDs for the popup
city: string;
status: '未开始' | '进行中' | '已完成' | '无法质检';
cannotQcReason?: string;
startTime?: string;
endTime?: string;
noPhotoCount: number;
manualInputCount: number;
envAbnormalCount: number;
isCheating: boolean; // 疑似作弊
cheatingReason?: string;
cheatingRemark?: string;
cheatingTime?: string;
}
export interface OrderQuery {
businessAccount?: string;
applyId?: string;
orderId?: string;
workerId?: string;
city?: string;
status?: string;
dateRange?: [string, string];
noPhotoCountMin?: number;
manualInputCountMin?: number;
envAbnormalCountMin?: number;
isAbnormal?: string; // 'all', 'abnormal', 'normal'
page: number;
pageSize: number;
}
// Detail record types for popups
export interface DetailRecord {
id: number;
process: string; // 流程/环节
reason?: string; // 原因
time: string; // 提交时间
}
export interface QcStep {
name: string;
duration: string; // e.g., "10s"
result: '通过' | '不通过';
imageUrl: string;
isAbnormal: number; // 0 or 1
}
export interface QcVideo {
id: string;
thumbnailUrl: string;
videoUrl: string;
name: string;
}
export interface OrderDetail extends Order {
installAddress: string; // 装机地址
orderType: string; // 工单类型
deviceType: string; // 设备类型
completeTime?: string;
totalDuration: string; // 总耗时
steps: QcStep[];
videos: QcVideo[];
}
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, VideoPlay, Download, Picture } from '@element-plus/icons-vue'
import type { OrderDetail } from '../types/order'
import { getMockOrderDetail } from '../mock/orderData'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const orderDetail = ref<OrderDetail | null>(null)
// Image Preview State
const previewImageList = ref<string[]>([])
const currentVideoUrl = ref('')
const videoDialogVisible = ref(false)
const envImagesVisible = ref(false)
const envImageList = ref<string[]>([])
// Fetch Data
const fetchDetail = () => {
loading.value = true
// Mock API call
setTimeout(() => {
const id = route.params.id as string
orderDetail.value = getMockOrderDetail(id)
// Prepare images for preview
if (orderDetail.value?.steps) {
previewImageList.value = orderDetail.value.steps.map(s => s.imageUrl)
}
loading.value = false
}, 500)
}
// Handlers
const handleBack = () => {
router.back()
}
const showEnvImages = () => {
// Show abnormal images (Mock: just pick images from steps that have abnormal flag, or all if none)
if (orderDetail.value?.steps) {
// Filter steps with abnormal or just take first 3 for demo
const abnormalSteps = orderDetail.value.steps.filter(s => s.isAbnormal > 0)
const sources = abnormalSteps.length > 0 ? abnormalSteps : orderDetail.value.steps.slice(0, 3)
envImageList.value = sources.map(s => s.imageUrl)
if (envImageList.value.length > 0) {
envImagesVisible.value = true
} else {
ElMessage.warning('暂无异常图片')
}
}
}
const playVideo = (url: string) => {
currentVideoUrl.value = url
videoDialogVisible.value = true
}
const downloadVideo = (url: string) => {
const link = document.createElement('a')
link.href = url
link.target = '_blank'
link.download = 'video.mp4'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const downloadAllVideos = () => {
ElMessage.success('已开始批量下载所有视频')
}
const downloadScreenshots = () => {
ElMessage.success('已开始下载质检截图')
}
onMounted(() => {
fetchDetail()
})
</script>
<template>
<div v-loading="loading" class="order-detail">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<el-button :icon="ArrowLeft" circle @click="handleBack" />
<h2 class="text-xl font-bold">质检工单详情</h2>
</div>
<div v-if="orderDetail">
<!-- 1. Basic Info -->
<el-card shadow="never" class="mb-4">
<template #header>
<div class="font-bold">基础信息</div>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item label="Apply_id">{{ orderDetail.applyId }}</el-descriptions-item>
<el-descriptions-item label="业务账号">{{ orderDetail.businessAccount }}</el-descriptions-item>
<el-descriptions-item label="所属地市">{{ orderDetail.city }}</el-descriptions-item>
<el-descriptions-item label="装机地址">{{ orderDetail.installAddress }}</el-descriptions-item>
<el-descriptions-item label="师傅工号">{{ orderDetail.workerId }}</el-descriptions-item>
<el-descriptions-item label="工单类型">{{ orderDetail.orderType }}</el-descriptions-item>
<el-descriptions-item label="设备类型">{{ orderDetail.deviceType }}</el-descriptions-item>
<el-descriptions-item label="工单ID">
<template v-for="id in orderDetail.orderIds" :key="id">
<div class="text-xs">{{ id }}</div>
</template>
</el-descriptions-item>
<el-descriptions-item label="质检状态">
<el-tag :type="orderDetail.status === '已完成' ? 'success' : ''">{{ orderDetail.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="无法质检原因">{{ orderDetail.cannotQcReason || '-' }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ orderDetail.startTime }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ orderDetail.completeTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="只能计数">
<div class="flex gap-4">
<span>不能拍: <span class="font-bold text-red-500">{{ orderDetail.noPhotoCount }}</span></span>
<span>手动: <span class="font-bold text-orange-500">{{ orderDetail.manualInputCount }}</span></span>
</div>
</el-descriptions-item>
<el-descriptions-item label="环境异常">
<span class="font-bold text-red-500 mr-2">{{ orderDetail.envAbnormalCount }}</span>
<el-button v-if="orderDetail.envAbnormalCount > 0" size="small" :icon="Picture" @click="showEnvImages">查看图片</el-button>
</el-descriptions-item>
<el-descriptions-item label="总耗时">{{ orderDetail.totalDuration }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 2. QC Details -->
<el-card shadow="never" class="mb-4">
<template #header>
<div class="font-bold">质检详情</div>
</template>
<el-table :data="orderDetail.steps" border style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="name" label="检测环节" />
<el-table-column prop="duration" label="环节耗时" />
<el-table-column prop="result" label="通过类型">
<template #default="{ row }">
<el-tag type="success" v-if="row.result === '通过'">{{ row.result }}</el-tag>
<el-tag type="danger" v-else>{{ row.result }}</el-tag>
</template>
</el-table-column>
<el-table-column label="查看图片">
<template #default="{ row, $index }">
<el-image
style="width: 50px; height: 50px"
:src="row.imageUrl"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="previewImageList"
:initial-index="$index"
fit="cover"
preview-teleported
hide-on-click-modal
/>
</template>
</el-table-column>
<el-table-column prop="isAbnormal" label="是否异常">
<template #default="{ row }">
<span :class="{'text-red-500 font-bold': row.isAbnormal > 0}">{{ row.isAbnormal }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 3. Videos -->
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<span class="font-bold">质检视频</span>
<div class="gap-2 flex">
<el-button type="primary" :icon="Download" @click="downloadAllVideos">下载全部视频</el-button>
<el-button type="success" :icon="Download" @click="downloadScreenshots">下载质检截图</el-button>
</div>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="video in orderDetail.videos" :key="video.id" class="border rounded-lg overflow-hidden relative group">
<!-- Video Thumbnail -->
<div class="aspect-video bg-black relative flex items-center justify-center cursor-pointer" @click="playVideo(video.videoUrl)">
<img :src="video.thumbnailUrl" class="w-full h-full object-cover opacity-80 hover:opacity-100 transition" />
<el-icon class="absolute text-white text-5xl opacity-80 group-hover:opacity-100 group-hover:scale-110 transition"><VideoPlay /></el-icon>
</div>
<!-- Video Footer -->
<div class="p-2 bg-gray-50 flex justify-between items-center">
<span class="text-sm truncate w-2/3" :title="video.name">{{ video.name }}</span>
<el-button link type="primary" :icon="Download" @click="downloadVideo(video.videoUrl)">下载</el-button>
</div>
</div>
</div>
<div v-if="orderDetail.videos.length === 0" class="text-center text-gray-400 py-8">
暂无视频
</div>
</el-card>
</div>
<!-- Video Player Dialog -->
<el-dialog v-model="videoDialogVisible" title="视频播放" width="800px" destroy-on-close align-center>
<video controls autoplay class="w-full max-h-[60vh] bg-black">
<source :src="currentVideoUrl" type="video/mp4">
您的浏览器不支持 Video 标签。
</video>
</el-dialog>
<!-- Environment Images Dialog (Using Image Viewer) -->
<el-image-viewer
v-if="envImagesVisible"
:url-list="envImageList"
@close="envImagesVisible = false"
/>
</div>
</template>
<style scoped>
:deep(.el-descriptions__label) {
font-weight: bold;
background-color: #fafafa;
}
</style>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { Search, Download, View, Edit, Warning } from '@element-plus/icons-vue'
import type { Order, OrderQuery } from '../types/order'
import { generateMockOrders } from '../mock/orderData'
import { ElMessage, ElMessageBox } from 'element-plus'
// --- State ---
const loading = ref(false)
const tableData = ref<Order[]>([])
const cities = ['南京市', '无锡市', '徐州市', '常州市', '苏州市', '南通市', '连云港市', '淮安市', '盐城市', '扬州市', '镇江市', '泰州市', '宿迁市']
const router = useRouter()
// Search Form
const queryForm = reactive<OrderQuery>({
businessAccount: '',
applyId: '',
orderId: '',
workerId: '',
city: '',
status: '',
dateRange: undefined,
noPhotoCountMin: undefined,
manualInputCountMin: undefined,
envAbnormalCountMin: undefined,
isAbnormal: '',
page: 1,
pageSize: 10
})
const total = ref(100) // Mock total
// --- Methods ---
const fetchData = () => {
loading.value = true
setTimeout(() => {
tableData.value = generateMockOrders(queryForm.pageSize)
loading.value = false
}, 500)
}
const handleSearch = () => {
queryForm.page = 1
fetchData()
}
const handleReset = () => {
// Reset logic
queryForm.businessAccount = ''
queryForm.applyId = ''
// ... others
handleSearch()
}
const handleExport = () => {
ElMessage.success('正在导出Excel...')
}
// Table Handlers
const handlePageChange = (val: number) => {
queryForm.page = val
fetchData()
}
// Dialogs State
const dialogVisible = reactive({
orderIds: false,
cannotQc: false,
markCheating: false,
cancelCheating: false,
details: false,
cheatingInfo: false
})
const currentOrder = ref<Order | null>(null)
const detailsType = ref<'noPhoto' | 'manual' | 'env'>('noPhoto')
const detailsTitle = ref('')
const detailsData = ref<DetailRecord[]>([])
// Forms
const cannotQcForm = reactive({
type: '',
reason: ''
})
const markCheatingForm = reactive({
reason: '',
remark: ''
})
// --- Actions ---
// 1. Order IDs
const openIdsDialog = (row: Order) => {
currentOrder.value = row
dialogVisible.orderIds = true
}
// 2. Numeric Details Popup
const openDetailsDialog = (row: Order, type: 'noPhoto' | 'manual' | 'env') => {
currentOrder.value = row
detailsType.value = type
if (type === 'noPhoto') {
detailsTitle.value = '无法拍摄明细'
// Mock details
detailsData.value = [
{ id: 1, process: '光猫识别', reason: '光线太暗', time: '2023-10-01 10:05:00' },
{ id: 2, process: '机顶盒识别', reason: '设备遮挡', time: '2023-10-01 10:10:00' }
]
} else if (type === 'manual') {
detailsTitle.value = '手动输入明细'
detailsData.value = [
{ id: 1, process: 'SN码输入', time: '2023-10-01 10:15:00' }
]
} else {
detailsTitle.value = '环境异常明细'
detailsData.value = [
{ id: 1, process: '背景检测', reason: '疑似非用户家', time: '2023-10-01 10:00:00' }
]
}
dialogVisible.details = true
}
// 3. Cheating Info Popup
const openCheatingInfo = (row: Order) => {
currentOrder.value = row
dialogVisible.cheatingInfo = true
}
// 4. Cannot QC Action
const openCannotQcDialog = (row: Order) => {
currentOrder.value = row
cannotQcForm.type = ''
cannotQcForm.reason = ''
dialogVisible.cannotQc = true
}
const submitCannotQc = () => {
if (!cannotQcForm.type || !cannotQcForm.reason) {
ElMessage.warning('请填写完整信息')
return
}
ElMessage.success('提交成功')
dialogVisible.cannotQc = false
// Refresh data logic here
}
// 5. Mark Cheating Action
const openMarkCheating = (row: Order) => {
currentOrder.value = row
markCheatingForm.reason = ''
markCheatingForm.remark = ''
dialogVisible.markCheating = true
}
const submitMarkCheating = () => {
if (!markCheatingForm.reason) {
ElMessage.warning('请选择异常原因')
return
}
ElMessage.success('标记成功')
dialogVisible.markCheating = false
if (currentOrder.value) currentOrder.value.isCheating = true
}
// 6. Cancel Cheating Action
const openCancelCheating = (row: Order) => {
ElMessageBox.confirm(
'确认要取消该工单的作弊标记吗?',
'取消作弊',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
ElMessage.success('取消成功')
row.isCheating = false
})
}
// Initial Fetch
fetchData()
</script>
<template>
<div class="p-4">
<!-- Search Area -->
<el-card shadow="never" class="mb-4">
<el-form :model="queryForm" label-width="90px" class="flex flex-wrap">
<el-form-item label="业务账号">
<el-input v-model="queryForm.businessAccount" placeholder="手机号/固话" clearable class="!w-48" />
</el-form-item>
<el-form-item label="Apply_id">
<el-input v-model="queryForm.applyId" placeholder="请输入" clearable class="!w-48" />
</el-form-item>
<el-form-item label="工单ID">
<el-input v-model="queryForm.orderId" placeholder="请输入" clearable class="!w-48" />
</el-form-item>
<el-form-item label="师傅工号">
<el-input v-model="queryForm.workerId" placeholder="请输入" clearable class="!w-48" />
</el-form-item>
<el-form-item label="所属地市">
<el-select v-model="queryForm.city" placeholder="请选择" clearable class="!w-48">
<el-option v-for="city in cities" :key="city" :label="city" :value="city" />
</el-select>
</el-form-item>
<el-form-item label="质检状态">
<el-select v-model="queryForm.status" placeholder="请选择" clearable class="!w-48">
<el-option label="全部" value="" />
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
<el-option label="无法质检" value="无法质检" />
</el-select>
</el-form-item>
<el-form-item label="质检时间">
<el-date-picker
v-model="queryForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
class="!w-80"
/>
</el-form-item>
<el-form-item label="无法拍摄">
<el-input-number v-model="queryForm.noPhotoCountMin" :min="1" controls-position="right" class="!w-32" placeholder=">=1" />
</el-form-item>
<el-form-item label="手动输入">
<el-input-number v-model="queryForm.manualInputCountMin" :min="1" controls-position="right" class="!w-32" placeholder=">=1" />
</el-form-item>
<el-form-item label="环境异常">
<el-input-number v-model="queryForm.envAbnormalCountMin" :min="1" controls-position="right" class="!w-32" placeholder=">=1" />
</el-form-item>
<el-form-item label="是否异常">
<el-select v-model="queryForm.isAbnormal" placeholder="请选择" clearable class="!w-32">
<el-option label="全部" value="" />
<el-option label="异常" value="abnormal" />
<el-option label="正常" value="normal" />
</el-select>
</el-form-item>
<el-form-item class="ml-auto">
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" :icon="Download" @click="handleExport">导出</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Table Area -->
<el-card shadow="never">
<el-table :data="tableData" v-loading="loading" border style="width: 100%">
<el-table-column prop="applyId" label="Apply_id" min-width="120" />
<el-table-column prop="workerId" label="师傅工号" width="100" />
<el-table-column prop="businessAccount" label="业务账号" min-width="120" />
<el-table-column label="工单ID" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openIdsDialog(row)">查看</el-button>
</template>
</el-table-column>
<el-table-column prop="city" label="所属地市" width="100" />
<el-table-column prop="status" label="质检状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '已完成' ? 'success' : row.status === '无法质检' ? 'info' : ''">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cannotQcReason" label="无法质检原因" min-width="120" show-overflow-tooltip />
<el-table-column prop="startTime" label="开始时间" width="160" />
<el-table-column prop="endTime" label="结束时间" width="160" />
<!-- Numeric Columns with Sort -->
<el-table-column prop="noPhotoCount" label="无法拍摄" sortable width="110">
<template #default="{ row }">
<span class="text-blue-500 cursor-pointer font-bold hover:underline"
v-if="row.noPhotoCount > 0"
@click="openDetailsDialog(row, 'noPhoto')">
{{ row.noPhotoCount }}
</span>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column prop="manualInputCount" label="手动输入" sortable width="110">
<template #default="{ row }">
<span class="text-blue-500 cursor-pointer font-bold hover:underline"
v-if="row.manualInputCount > 0"
@click="openDetailsDialog(row, 'manual')">
{{ row.manualInputCount }}
</span>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column prop="envAbnormalCount" label="环境异常" sortable width="110">
<template #default="{ row }">
<span class="text-blue-500 cursor-pointer font-bold hover:underline"
v-if="row.envAbnormalCount > 0"
@click="openDetailsDialog(row, 'env')">
{{ row.envAbnormalCount }}
</span>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column label="疑似作弊" width="100">
<template #default="{ row }">
<span v-if="row.isCheating" class="text-red-500 cursor-pointer hover:underline" @click="openCheatingInfo(row)"></span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="router.push(`/order-detail/${row.id}`)">详情</el-button>
<el-button link type="warning" @click="openCannotQcDialog(row)">无法质检</el-button>
<el-button link type="danger" v-if="!row.isCheating" @click="openMarkCheating(row)">标记作弊</el-button>
<el-button link type="info" v-else @click="openCancelCheating(row)">取消作弊</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryForm.page"
v-model:page-size="queryForm.pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- Dialog: Order IDs -->
<el-dialog v-model="dialogVisible.orderIds" title="工单ID列表" width="400px">
<div v-if="currentOrder">
<p class="mb-2"><strong>Apply_id:</strong> {{ currentOrder.applyId }}</p>
<p class="mb-4"><strong>业务账号:</strong> {{ currentOrder.businessAccount }}</p>
<el-table :data="currentOrder.orderIds.map(id => ({ id }))" border stripe max-height="300">
<el-table-column prop="id" label="工单ID" />
</el-table>
</div>
</el-dialog>
<!-- Dialog: Numeric Details (NoPhoto/Manual/Env) -->
<el-dialog v-model="dialogVisible.details" :title="detailsTitle" width="600px">
<el-table :data="detailsData" border stripe>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="process" label="流程" />
<el-table-column prop="reason" label="原因" v-if="detailsType !== 'manual'" />
<el-table-column prop="time" label="提交时间" />
</el-table>
</el-dialog>
<!-- Dialog: Cheating Info -->
<el-dialog v-model="dialogVisible.cheatingInfo" title="疑似作弊详情" width="400px">
<div v-if="currentOrder">
<p class="mb-2"><strong>疑似作弊原因:</strong> {{ currentOrder.cheatingReason }}</p>
<p class="mb-2"><strong>备注:</strong> {{ currentOrder.cheatingRemark || '无' }}</p>
<p class="mb-2"><strong>标记时间:</strong> {{ currentOrder.cheatingTime }}</p>
</div>
</el-dialog>
<!-- Dialog: Cannot QC Form -->
<el-dialog v-model="dialogVisible.cannotQc" title="无法质检处理" width="500px">
<el-form label-width="80px">
<el-form-item label="原因类型" required>
<el-radio-group v-model="cannotQcForm.type">
<el-radio value="个人原因">个人原因</el-radio>
<el-radio value="用户原因">用户原因</el-radio>
<el-radio value="其他">其他</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="具体原因" required>
<el-input type="textarea" v-model="cannotQcForm.reason" placeholder="请填写具体原因" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible.cannotQc = false">取消</el-button>
<el-button type="primary" @click="submitCannotQc">提交</el-button>
</template>
</el-dialog>
<!-- Dialog: Mark Cheating Form -->
<el-dialog v-model="dialogVisible.markCheating" title="标记作弊" width="500px">
<div v-if="currentOrder" class="mb-4 p-2 bg-gray-50 rounded">
<p class="text-sm text-gray-600">Apply_id: {{ currentOrder.applyId }}</p>
<p class="text-sm text-gray-600">业务账号: {{ currentOrder.businessAccount }}</p>
</div>
<el-form label-width="80px">
<el-form-item label="异常原因" required>
<el-select v-model="markCheatingForm.reason" class="w-full">
<el-option value="在家质检" label="在家质检" />
<el-option value="一直点击无法质检" label="一直点击无法质检" />
<el-option value="其他" label="其他" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input type="textarea" v-model="markCheatingForm.remark" placeholder="选填" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible.markCheating = false">取消</el-button>
<el-button type="primary" @click="submitMarkCheating">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.el-form-item {
margin-right: 16px;
margin-bottom: 16px;
}
</style>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { Search, Download, Picture, VideoPlay } from '@element-plus/icons-vue'
const queryForm = reactive({
dateRange: [],
page: 1,
pageSize: 10
})
const deviceStats = ref([
{ name: '工服', t1: 120, t1p: 40, t2: 80, t2p: 25, t3: 50, t3p: 15, t4: 20, t4p: 6, t5: 10, t5p: 3, t6: 5, t6p: 1.5, t6plus: 15, t6plus_p: 4.5 },
{ name: '光猫', t1: 100, t1p: 33, t2: 90, t2p: 30, t3: 60, t3p: 20, t4: 30, t4p: 10, t5: 10, t5p: 3, t6: 5, t6p: 1.5, t6plus: 5, t6plus_p: 1.5 },
{ name: '机顶盒', t1: 150, t1p: 50, t2: 60, t2p: 20, t3: 40, t3p: 13, t4: 20, t4p: 6, t5: 10, t5p: 3, t6: 5, t6p: 1.5, t6plus: 15, t6plus_p: 5 },
])
// Dialogs
const exportImgDialog = ref(false)
const videoDialog = ref(false)
</script>
<template>
<div class="device-stats-page">
<!-- Filter -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" :model="queryForm">
<el-form-item label="日期">
<el-date-picker
v-model="queryForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search">查询</el-button>
<el-button type="success" :icon="Download">导出明细</el-button>
<el-button :icon="Picture" @click="exportImgDialog = true">导出图片</el-button>
<el-button :icon="VideoPlay" @click="videoDialog = true">查看视频</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Table -->
<el-card shadow="never">
<el-table :data="deviceStats" border stripe>
<el-table-column prop="name" label="设备名称" fixed width="120" />
<el-table-column prop="t1" label="识别1次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t1 }}</div><div class="text-xs text-gray-400">{{ row.t1p }}%</div>
</template>
</el-table-column>
<el-table-column prop="t2" label="识别2次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t2 }}</div><div class="text-xs text-gray-400">{{ row.t2p }}%</div>
</template>
</el-table-column>
<el-table-column prop="t3" label="识别3次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t3 }}</div><div class="text-xs text-gray-400">{{ row.t3p }}%</div>
</template>
</el-table-column>
<el-table-column prop="t4" label="识别4次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t4 }}</div><div class="text-xs text-gray-400">{{ row.t4p }}%</div>
</template>
</el-table-column>
<el-table-column prop="t5" label="识别5次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t5 }}</div><div class="text-xs text-gray-400">{{ row.t5p }}%</div>
</template>
</el-table-column>
<el-table-column prop="t6" label="识别6次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t6 }}</div><div class="text-xs text-gray-400">{{ row.t6p }}%</div>
</template>
</el-table-column>
<el-table-column prop="t6plus" label=">6次" align="center" sortable>
<template #default="{ row }">
<div>{{ row.t6plus }}</div><div class="text-xs text-gray-400">{{ row.t6plus_p }}%</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Export Image Dialog -->
<el-dialog v-model="exportImgDialog" title="导出图片配置" width="500px">
<el-form label-width="100px">
<el-form-item label="日期范围">
<el-date-picker type="datetimerange" />
</el-form-item>
<el-form-item label="选择设备">
<el-select placeholder="请选择">
<el-option label="工服" value="工服" />
<el-option label="光猫" value="光猫" />
</el-select>
</el-form-item>
<el-form-item label="导出数量">
<el-input-number :min="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="exportImgDialog = false">取消</el-button>
<el-button type="primary">确定导出</el-button>
</template>
</el-dialog>
<!-- Video List Dialog Placeholder -->
<el-dialog v-model="videoDialog" title="查看视频" width="800px">
<el-form :inline="true">
<el-form-item label="日期"><el-date-picker type="datetimerange" /></el-form-item>
<el-form-item label="地区"><el-select placeholder="请选择"><el-option label="南京" value="南京"/></el-select></el-form-item>
<el-form-item><el-button type="primary">查询</el-button></el-form-item>
</el-form>
<el-table :data="[]" border height="400">
<el-table-column label="视频ID" />
<el-table-column label="链接" />
</el-table>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, Download } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
const queryForm = reactive({
dateRange: [],
city: ''
})
const cities = ['南京市', '无锡市', '徐州市', '常州市', '苏州市', '南通市', '连云港市', '淮安市', '盐城市', '扬州市', '镇江市', '泰州市', '宿迁市']
const cityTableData = ref([
{ city: '南京市', count1: 100, count1p: '50%', count2: 50, count2p: '25%', count3: 30, count3p: '15%', count4: 20, count4p: '10%', total: 200, rate: '2.5%' },
{ city: '苏州市', count1: 90, count1p: '45%', count2: 60, count2p: '30%', count3: 30, count3p: '15%', count4: 20, count4p: '10%', total: 200, rate: '2.2%' },
])
const deviceTableData = ref([
{ device: '光猫', count1: 50, count1p: '50%', count2: 25, count2p: '25%', count3: 15, count3p: '15%', count4: 10, count4p: '10%', total: 100, rate: '1.2%' },
])
const chartRef = ref<HTMLElement>()
const initChart = () => {
if (chartRef.value) {
const chart = echarts.init(chartRef.value)
chart.setOption({
title: { text: '地市不能拍次数占比统计' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['1次', '2次', '3次', '4次及以上'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: { type: 'category', data: ['南京', '无锡', '徐州', '常州', '苏州', '南通', '连云港', '淮安', '盐城', '扬州', '镇江', '泰州', '宿迁'], axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', name: '占比%' },
series: [
{ name: '1次', type: 'bar', stack: 'total', label: { show: true }, data: [50, 45, 60, 55, 40, 50, 60, 50, 55, 45, 60, 50, 40] },
{ name: '2次', type: 'bar', stack: 'total', label: { show: true }, data: [25, 30, 20, 25, 30, 25, 20, 25, 20, 30, 20, 25, 30] },
{ name: '3次', type: 'bar', stack: 'total', label: { show: true }, data: [15, 15, 10, 10, 20, 15, 10, 15, 15, 15, 10, 15, 20] },
{ name: '4次及以上', type: 'bar', stack: 'total', label: { show: true }, data: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10] }
]
})
}
}
onMounted(() => {
initChart()
})
</script>
<template>
<div class="nophoto-stats-page">
<!-- Filter -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" :model="queryForm">
<el-form-item label="日期">
<el-date-picker
v-model="queryForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="地区">
<el-select v-model="queryForm.city" placeholder="请选择" class="!w-40" clearable>
<el-option v-for="c in cities" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Chart -->
<el-card shadow="never" class="mb-4">
<div ref="chartRef" style="height: 400px"></div>
</el-card>
<!-- Tables -->
<div class="grid grid-cols-1 gap-4">
<el-card shadow="never">
<template #header>
<div class="flex justify-between">
<span class="font-bold">地市不能拍次数统计</span>
<el-button link type="primary" :icon="Download">导出</el-button>
</div>
</template>
<el-table :data="cityTableData" border stripe>
<el-table-column prop="city" label="地市" />
<el-table-column label="1次不能拍">
<template #default="{row}">{{row.count1}} ({{row.count1p}})</template>
</el-table-column>
<el-table-column label="2次不能拍">
<template #default="{row}">{{row.count2}} ({{row.count2p}})</template>
</el-table-column>
<el-table-column label="3次不能拍">
<template #default="{row}">{{row.count3}} ({{row.count3p}})</template>
</el-table-column>
<el-table-column label="4次及以上">
<template #default="{row}">{{row.count4}} ({{row.count4p}})</template>
</el-table-column>
<el-table-column prop="total" label="不能拍总次数" sortable />
<el-table-column prop="rate" label="占比(本市)" sortable />
</el-table>
</el-card>
<el-card shadow="never">
<template #header>
<div class="flex justify-between">
<span class="font-bold">设备不能拍次数统计</span>
<el-button link type="primary" :icon="Download">导出</el-button>
</div>
</template>
<el-table :data="deviceTableData" border stripe>
<el-table-column prop="device" label="设备名称" />
<el-table-column label="1次不能拍">
<template #default="{row}">{{row.count1}} ({{row.count1p}})</template>
</el-table-column>
<el-table-column label="2次不能拍">
<template #default="{row}">{{row.count2}} ({{row.count2p}})</template>
</el-table-column>
<el-table-column label="3次不能拍">
<template #default="{row}">{{row.count3}} ({{row.count3p}})</template>
</el-table-column>
<el-table-column label="4次及以上">
<template #default="{row}">{{row.count4}} ({{row.count4p}})</template>
</el-table-column>
<el-table-column prop="total" label="不能拍总次数" sortable />
<el-table-column prop="rate" label="占比(设备)" sortable />
</el-table>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { Search, Download, DataLine } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
const loading = ref(false)
const queryForm = ref({
dateRange: [] as string[],
city: ''
})
// Statistics Data
const stats = ref({
total: 12580,
completed: 11200,
completedRate: 89.03,
qcTotal: 12580,
qcCompleted: 11000,
qcCompletedRate: 87.44,
complaintTotal: 50,
complaintCompleted: 45,
complaintRate: 90,
cannotQc: 200,
cannotQcRate: 1.59
})
// --- Constants ---
const cityList = ['南京市', '无锡市', '徐州市', '常州市', '苏州市', '南通市', '连云港市', '淮安市', '盐城市', '扬州市', '镇江市', '泰州市', '宿迁市']
const processList = [
'工服', '工牌', '账号', '环境',
'普通光猫', '普通光猫串号', '主光猫', '主光猫串号',
'子光猫', '子光猫串号', '机顶盒', '机顶盒串号',
'路由器', '路由器串号', '电视画面', '电视软终端串号',
'云电脑终端', '云电脑终端串号', '云电脑', '室内摄像头',
'室内摄像头串号', '室外摄像头', '故障投诉光猫灯', '故障投诉主光猫灯'
]
// --- Refs ---
const cityStatsTab = ref('order') // 'order', 'qc', 'complaint', 'cannotQc'
const timeStatsTab = ref('dist') // 'dist', 'avg'
const processStatsTab = ref('avgCount') // 'avgCount', 'totalCount', 'avgTime'
const processLimit = ref(10) // 10, 25, 1000(all)
// Toggle Table Visibility
const showCityTable = ref(false)
const showTimeTable = ref(false)
const showProcessTable = ref(false)
const orderChartRef = ref<HTMLElement>()
const qcChartRef = ref<HTMLElement>()
const complaintChartRef = ref<HTMLElement>()
const cannotQcChartRef = ref<HTMLElement>()
const timeDistChartRef = ref<HTMLElement>()
const avgTimeChartRef = ref<HTMLElement>()
const processAvgCountChartRef = ref<HTMLElement>()
const processTotalCountChartRef = ref<HTMLElement>()
const processAvgTimeChartRef = ref<HTMLElement>()
// --- Data ---
const cityTableData = ref(cityList.map(city => ({
date: '10-01', city,
total: 300, totalDone: 250, totalUndone: 50,
qcTotal: 280, qcDone: 240, qcUndone: 40,
complaintTotal: 10, complaintDone: 8, complaintUndone: 2,
cannotQc: 5
})))
const timeTableData = ref(cityList.map(city => {
const gt5 = Math.floor(Math.random() * 20)
const bt45 = Math.floor(Math.random() * 30)
const bt34 = Math.floor(Math.random() * 50)
const lt3 = Math.floor(Math.random() * 100)
return {
date: '10-01', city,
gt5, bt45, bt34, lt3,
total: gt5 + bt45 + bt34 + lt3,
avgTime: Math.floor(Math.random() * 100 + 100)
}
}))
const processTableData = ref(processList.map((name, i) => ({
date: '10-01', name,
totalCount: 1000 + i * 100,
avgTime: (5 + Math.random() * 5).toFixed(2),
avgCount: (1 + Math.random()).toFixed(2)
})))
// --- Chart Renderers ---
const renderOrderChart = () => {
if (!orderChartRef.value) return
const chart = echarts.getInstanceByDom(orderChartRef.value) || echarts.init(orderChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['工单总数', '完成工单数', '未完成工单数'], bottom: 0 },
xAxis: { type: 'category', data: cityList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '10%', top: '3%', containLabel: true },
series: [
{ name: '工单总数', type: 'bar', data: cityList.map(() => 300 + Math.random() * 50) },
{ name: '完成工单数', type: 'bar', data: cityList.map(() => 250 + Math.random() * 40) },
{ name: '未完成工单数', type: 'bar', data: cityList.map(() => 50 + Math.random() * 10) }
]
})
chart.resize()
}
const renderQcChart = () => {
if (!qcChartRef.value) return
const chart = echarts.getInstanceByDom(qcChartRef.value) || echarts.init(qcChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['质检工单总数', '完成质检工单数', '未完成质检工单数'], bottom: 0 },
xAxis: { type: 'category', data: cityList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '10%', top: '3%', containLabel: true },
series: [
{ name: '质检工单总数', type: 'bar', data: cityList.map(() => 280 + Math.random() * 40), itemStyle: { color: '#67C23A' } },
{ name: '完成质检工单数', type: 'bar', data: cityList.map(() => 240 + Math.random() * 30), itemStyle: { color: '#95d475' } },
{ name: '未完成质检工单数', type: 'bar', data: cityList.map(() => 40 + Math.random() * 10), itemStyle: { color: '#b3e19d' } }
]
})
chart.resize()
}
const renderComplaintChart = () => {
if (!complaintChartRef.value) return
const chart = echarts.getInstanceByDom(complaintChartRef.value) || echarts.init(complaintChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['投诉工单总数', '完成投诉工单数', '未完成投诉工单数'], bottom: 0 },
xAxis: { type: 'category', data: cityList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '10%', top: '3%', containLabel: true },
series: [
{ name: '投诉工单总数', type: 'bar', data: cityList.map(() => 10 + Math.random() * 5), itemStyle: { color: '#E6A23C' } },
{ name: '完成投诉工单数', type: 'bar', data: cityList.map(() => 8 + Math.random() * 4), itemStyle: { color: '#f3d19e' } },
{ name: '未完成投诉工单数', type: 'bar', data: cityList.map(() => 2 + Math.random() * 2), itemStyle: { color: '#faecd8' } }
]
})
chart.resize()
}
const renderCannotQcChart = () => {
if (!cannotQcChartRef.value) return
const chart = echarts.getInstanceByDom(cannotQcChartRef.value) || echarts.init(cannotQcChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: cityList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '3%', top: '3%', containLabel: true },
series: [
{ name: '无法质检数', type: 'bar', data: cityList.map(() => 5 + Math.random() * 5), itemStyle: { color: '#909399' } }
]
})
chart.resize()
}
const renderTimeDistChart = () => {
if (!timeDistChartRef.value) return
const chart = echarts.getInstanceByDom(timeDistChartRef.value) || echarts.init(timeDistChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['>5分钟', '4-5分钟', '3-4分钟', '<3分钟'], bottom: 0 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: cityList },
grid: { left: '3%', right: '4%', bottom: '10%', top: '3%', containLabel: true },
series: [
{ name: '>5分钟', type: 'bar', stack: 'total', data: cityList.map(() => Math.random() * 20) },
{ name: '4-5分钟', type: 'bar', stack: 'total', data: cityList.map(() => Math.random() * 30) },
{ name: '3-4分钟', type: 'bar', stack: 'total', data: cityList.map(() => Math.random() * 40) },
{ name: '<3分钟', type: 'bar', stack: 'total', data: cityList.map(() => Math.random() * 50) }
]
})
chart.resize()
}
const renderAvgTimeChart = () => {
if (!avgTimeChartRef.value) return
const chart = echarts.getInstanceByDom(avgTimeChartRef.value) || echarts.init(avgTimeChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: cityList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', name: '秒' },
grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true },
series: [
{
name: '平均耗时', type: 'line',
data: cityList.map(() => 150 + Math.random() * 50),
markLine: {
data: [{ type: 'average', name: '全省平均' }]
}
}
]
})
chart.resize()
}
const renderProcessAvgCountChart = () => {
if (!processAvgCountChartRef.value) return
const chart = echarts.getInstanceByDom(processAvgCountChartRef.value) || echarts.init(processAvgCountChartRef.value)
const limit = processLimit.value
const currentData = processList.slice(0, limit)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['平均识别次数'], bottom: 0 },
xAxis: { type: 'category', data: currentData, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
series: [
{ name: '平均识别次数', type: 'bar', data: currentData.map(() => 1 + Math.random()), itemStyle: { color: '#409EFF' } }
]
}, true)
chart.resize()
}
const renderProcessTotalCountChart = () => {
if (!processTotalCountChartRef.value) return
const chart = echarts.getInstanceByDom(processTotalCountChartRef.value) || echarts.init(processTotalCountChartRef.value)
const limit = processLimit.value
const currentData = processList.slice(0, limit)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['识别总次数'], bottom: 0 },
xAxis: { type: 'category', data: currentData, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
series: [
{ name: '识别总次数', type: 'bar', data: currentData.map(() => 1000 + Math.random() * 500), itemStyle: { color: '#67C23A' } }
]
}, true)
chart.resize()
}
const renderProcessAvgTimeChart = () => {
if (!processAvgTimeChartRef.value) return
const chart = echarts.getInstanceByDom(processAvgTimeChartRef.value) || echarts.init(processAvgTimeChartRef.value)
const limit = processLimit.value
const currentData = processList.slice(0, limit)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['平均耗时(秒)'], bottom: 0 },
xAxis: { type: 'category', data: currentData, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
series: [
{ name: '平均耗时(秒)', type: 'bar', data: currentData.map(() => 5 + Math.random() * 5), itemStyle: { color: '#E6A23C' } }
]
}, true)
chart.resize()
}
// --- Tab Handlers ---
const handleTabChange = (name: any) => {
nextTick(() => {
if (name === 'order') renderOrderChart()
if (name === 'qc') renderQcChart()
if (name === 'complaint') renderComplaintChart()
if (name === 'cannotQc') renderCannotQcChart()
})
}
const handleTimeTabChange = (name: any) => {
nextTick(() => {
if (name === 'dist') renderTimeDistChart()
if (name === 'avg') renderAvgTimeChart()
})
}
const handleProcessTabChange = (name: any) => {
nextTick(() => {
if (name === 'avgCount') renderProcessAvgCountChart()
if (name === 'totalCount') renderProcessTotalCountChart()
if (name === 'avgTime') renderProcessAvgTimeChart()
})
}
// Watch limit change to re-render current tab chart
watch(processLimit, () => {
handleProcessTabChange(processStatsTab.value)
})
const initCharts = () => {
// Init default active tabs
renderOrderChart()
renderTimeDistChart()
renderProcessAvgCountChart()
}
onMounted(() => {
initCharts()
})
</script>
<template>
<div class="stats-quality-page">
<!-- Filter -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" :model="queryForm">
<el-form-item label="日期">
<el-date-picker
v-model="queryForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="地区">
<el-select v-model="queryForm.city" placeholder="请选择" class="!w-48" clearable>
<el-option v-for="c in cityList" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search">查询</el-button>
<el-button type="success" :icon="Download">导出表格</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Cards -->
<div class="grid grid-cols-4 gap-4 mb-4">
<!-- ... keep cards ... -->
<el-card shadow="never" class="bg-blue-50">
<div class="text-gray-500 mb-2">工单总数</div>
<div class="text-2xl font-bold">{{ stats.total }}</div>
<div class="text-sm text-gray-500 mt-2">已完成: {{ stats.completed }} ({{ stats.completedRate }}%)</div>
</el-card>
<el-card shadow="never" class="bg-green-50">
<div class="text-gray-500 mb-2">质检工单总数</div>
<div class="text-2xl font-bold text-green-600">{{ stats.qcTotal }}</div>
<div class="text-sm text-gray-500 mt-2">已完成: {{ stats.qcCompleted }} ({{ stats.qcCompletedRate }}%)</div>
</el-card>
<el-card shadow="never" class="bg-orange-50">
<div class="text-gray-500 mb-2">投诉工单总数</div>
<div class="text-2xl font-bold text-orange-600">{{ stats.complaintTotal }}</div>
<div class="text-sm text-gray-500 mt-2">已完成: {{ stats.complaintCompleted }} ({{ stats.complaintRate }}%)</div>
</el-card>
<el-card shadow="never" class="bg-gray-50">
<div class="text-gray-500 mb-2">无法质检数</div>
<div class="text-2xl font-bold text-gray-600">{{ stats.cannotQc }}</div>
<div class="text-sm text-gray-500 mt-2">占比: {{ stats.cannotQcRate }}%</div>
</el-card>
</div>
<!-- 1. City Order Stats Section (Tabs) -->
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<div class="font-bold">地市工单统计</div>
<el-button link type="primary" @click="showCityTable = !showCityTable">
{{ showCityTable ? '隐藏详细数据' : '查看详细数据' }}
</el-button>
</div>
</template>
<!-- Table -->
<el-table v-show="showCityTable" :data="cityTableData" border stripe height="250" class="mb-4">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column prop="city" label="地区" width="100" />
<el-table-column label="工单总数" align="center">
<el-table-column prop="total" label="总数" sortable />
<el-table-column prop="totalDone" label="已完成" />
<el-table-column prop="totalUndone" label="未完成" />
</el-table-column>
<el-table-column label="质检工单数" align="center">
<el-table-column prop="qcTotal" label="总数" sortable />
<el-table-column prop="qcDone" label="已完成" />
<el-table-column prop="qcUndone" label="未完成" />
</el-table-column>
<el-table-column label="投诉工单数" align="center">
<el-table-column prop="complaintTotal" label="总数" sortable />
<el-table-column prop="complaintDone" label="已完成" />
<el-table-column prop="complaintUndone" label="未完成" />
</el-table-column>
<el-table-column prop="cannotQc" label="无法质检数" sortable />
</el-table>
<el-tabs v-model="cityStatsTab" class="mb-4" @tab-change="handleTabChange">
<el-tab-pane label="工单数统计" name="order">
<div ref="orderChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
<el-tab-pane label="质检工单数统计" name="qc">
<div ref="qcChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
<el-tab-pane label="投诉工单数统计" name="complaint">
<div ref="complaintChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
<el-tab-pane label="无法质检工单数统计" name="cannotQc">
<div ref="cannotQcChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 2. Time Stats Section (Tabs) -->
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<div class="font-bold">耗时统计</div>
<el-button link type="primary" @click="showTimeTable = !showTimeTable">
{{ showTimeTable ? '隐藏详细数据' : '查看详细数据' }}
</el-button>
</div>
</template>
<el-table v-show="showTimeTable" :data="timeTableData" border stripe height="250" class="mb-4">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column prop="city" label="地市" width="100" />
<el-table-column prop="gt5" label=">5分钟" sortable />
<el-table-column prop="bt45" label="4≤x<5分钟" sortable />
<el-table-column prop="bt34" label="3≤x<4分钟" sortable />
<el-table-column prop="lt3" label="<3分钟" sortable />
<el-table-column prop="total" label="总计" sortable />
<el-table-column prop="avgTime" label="平均耗时(s)" sortable />
</el-table>
<el-tabs v-model="timeStatsTab" class="mb-4" @tab-change="handleTimeTabChange">
<el-tab-pane label="地市耗时分布" name="dist">
<div ref="timeDistChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
<el-tab-pane label="平均耗时" name="avg">
<div ref="avgTimeChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 3. Process Stats Section (New) -->
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<div class="font-bold">质检环节统计</div>
<el-button link type="primary" @click="showProcessTable = !showProcessTable">
{{ showProcessTable ? '隐藏详细数据' : '查看详细数据' }}
</el-button>
</div>
</template>
<!-- Table -->
<el-table v-show="showProcessTable" :data="processTableData" border stripe height="250" class="mb-4">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column prop="name" label="环节名称" />
<el-table-column prop="totalCount" label="识别总次数" sortable />
<el-table-column prop="avgTime" label="平均耗时(s)" sortable />
<el-table-column prop="avgCount" label="平均识别次数" sortable />
</el-table>
<div class="relative">
<div class="absolute right-0 top-0 z-10 flex items-center gap-2">
<span class="text-sm">显示数量:</span>
<el-select v-model="processLimit" size="small" style="width: 100px">
<el-option label="10条" :value="10" />
<el-option label="15条" :value="15" />
<el-option label="全部" :value="1000" />
</el-select>
</div>
<el-tabs v-model="processStatsTab" class="mb-4" @tab-change="handleProcessTabChange">
<el-tab-pane label="平均识别次数" name="avgCount">
<div ref="processAvgCountChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
<el-tab-pane label="识别总次数" name="totalCount">
<div ref="processTotalCountChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
<el-tab-pane label="平均耗时" name="avgTime">
<div ref="processAvgTimeChartRef" style="width: 100%; height: 400px;"></div>
</el-tab-pane>
</el-tabs>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick, watch } from 'vue'
import { Search, Download } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
const queryForm = reactive({
dateRange: [] as string[],
page: 1,
pageSize: 10
})
// Detailed Serial Device List
const deviceList = [
'普通光猫串号', '机顶盒串号', '路由器串号', '从光猫串号',
'主光猫串号', '室内安防串号', '软终端串号', '云电脑终端串号', 'POE交换机串号'
]
const summary = ref({
total: 5000,
autoRate: 85.5,
pass1: 70.2,
pass2: 15.3,
pass3: 10.1,
avgTime: 12.5,
avgAttempts: 1.4
})
const tableData = ref(deviceList.map(name => ({
name,
total: Math.floor(Math.random() * 1000 + 500),
pass1: (Math.random() * 20 + 60).toFixed(1) + '%',
pass2: (Math.random() * 10 + 10).toFixed(1) + '%',
pass3: (Math.random() * 5 + 5).toFixed(1) + '%',
pass3in: '95%',
autoRate: (Math.random() * 15 + 75).toFixed(1) + '%',
avgCount: (Math.random() * 0.5 + 1).toFixed(1),
avgTime: (Math.random() * 5 + 8).toFixed(1) + 's'
})))
// Computed property to check if date range > 1 day
const isMultiDay = computed(() => {
if (!queryForm.dateRange || queryForm.dateRange.length !== 2) return false
const start = dayjs(queryForm.dateRange[0])
const end = dayjs(queryForm.dateRange[1])
return end.diff(start, 'day') >= 1
})
// Tab State (Only for Multi-Day)
const trendTab = ref('overall') // 'overall', 'details'
const passRateChartRef = ref<HTMLElement>()
const autoRateChartRef = ref<HTMLElement>()
const trendChartRef = ref<HTMLElement>()
const deviceTrendChartRef = ref<HTMLElement>()
// New Trend Charts Refs
const overallPassRateTrendRef = ref<HTMLElement>()
const overallAutoRateTrendRef = ref<HTMLElement>()
const devicePassRateChartRef = ref<HTMLElement>() // For Device Detail Tab - Pass Rate
const deviceAutoRateChartRef = ref<HTMLElement>() // For Device Detail Tab - Auto Rate
// Chart instances
let passRateChart: echarts.ECharts | null = null
let trendChart: echarts.ECharts | null = null
let overallPassRateChart: echarts.ECharts | null = null
let overallAutoRateChart: echarts.ECharts | null = null
let devicePassRateChart: echarts.ECharts | null = null
let deviceAutoRateChart: echarts.ECharts | null = null
/* -------------------------------------------------------------------------- */
/* SINGLE DAY CHARTS */
/* -------------------------------------------------------------------------- */
const initSingleDayCharts = () => {
// 1. Device Pass Rate Bar Chart
if (passRateChartRef.value) {
passRateChart = echarts.getInstanceByDom(passRateChartRef.value) || echarts.init(passRateChartRef.value)
passRateChart.setOption({
title: { text: '设备识别通过率' },
tooltip: { trigger: 'axis' },
legend: { data: ['1次通过', '2次通过', '3次通过'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: { type: 'category', data: deviceList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', name: '%' },
series: [
{ name: '1次通过', type: 'bar', data: deviceList.map(() => Math.random() * 20 + 60) },
{ name: '2次通过', type: 'bar', data: deviceList.map(() => Math.random() * 10 + 10) },
{ name: '3次通过', type: 'bar', data: deviceList.map(() => Math.random() * 5 + 5) }
]
})
passRateChart.resize()
}
// 2. Device Auto Identify Rate Chart (Single Day)
if (trendChartRef.value) {
trendChart = echarts.getInstanceByDom(trendChartRef.value) || echarts.init(trendChartRef.value)
trendChart.setOption({
title: { text: '设备自动识别通过率' },
tooltip: { trigger: 'axis' },
legend: { data: ['自动识别率'], bottom: 0 },
xAxis: { type: 'category', data: deviceList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', name: '%' },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
series: [
{ name: '自动识别率', type: 'bar', data: deviceList.map(() => Math.random() * 10 + 80), itemStyle: { color: '#67C23A' } }
]
})
trendChart.resize()
}
}
/* -------------------------------------------------------------------------- */
/* MULTI DAY CHARTS */
/* -------------------------------------------------------------------------- */
const initMultiDayCharts = () => {
// Generate dates based on selection, or mock if not selected
let dates = []
if (queryForm.dateRange && queryForm.dateRange.length === 2) {
let current = dayjs(queryForm.dateRange[0])
const end = dayjs(queryForm.dateRange[1])
while(current.isBefore(end) || current.isSame(end, 'day')) {
dates.push(current.format('YYYY-MM-DD'))
current = current.add(1, 'day')
}
// Limit to avoid too many points for mock
if(dates.length > 20) dates = dates.slice(0, 20)
} else {
// Default 7 days
for(let i=0; i<7; i++) dates.push(dayjs().subtract(6-i, 'day').format('YYYY-MM-DD'))
}
if (trendTab.value === 'overall') {
// 1. Overall Pass Rate Trend: Stacked or Multi-Line for 1st, 2nd, 3rd pass rates
if (overallPassRateTrendRef.value) {
overallPassRateChart = echarts.getInstanceByDom(overallPassRateTrendRef.value) || echarts.init(overallPassRateTrendRef.value)
overallPassRateChart.setOption({
title: { text: '整体通过率时间趋势' },
tooltip: { trigger: 'axis' },
legend: { data: ['1次通过率', '2次通过率', '3次通过率'], bottom: 0 },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value', name: '%' },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
series: [
{ name: '1次通过率', type: 'line', data: dates.map(() => Math.random() * 10 + 70), smooth: true },
{ name: '2次通过率', type: 'line', data: dates.map(() => Math.random() * 5 + 15), smooth: true },
{ name: '3次通过率', type: 'line', data: dates.map(() => Math.random() * 5 + 5), smooth: true }
]
}, true)
overallPassRateChart.resize()
}
// 2. Overall Auto Rate Trend
if (overallAutoRateTrendRef.value) {
overallAutoRateChart = echarts.getInstanceByDom(overallAutoRateTrendRef.value) || echarts.init(overallAutoRateTrendRef.value)
overallAutoRateChart.setOption({
title: { text: '整体自动识别率时间趋势' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value', name: '%' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
series: [{ name: '自动识别率', type: 'line', data: dates.map(() => Math.random() * 10 + 80), smooth: true, itemStyle: { color: '#67C23A' } }]
}, true)
overallAutoRateChart.resize()
}
} else {
// 'details' Tab
// 1. Device Pass Rate Stats (New Chart requested)
nextTick(() => {
if (devicePassRateChartRef.value) {
devicePassRateChart = echarts.getInstanceByDom(devicePassRateChartRef.value) || echarts.init(devicePassRateChartRef.value)
devicePassRateChart.setOption({
title: { text: '设备识别通过率' },
tooltip: { trigger: 'axis' },
legend: { data: ['1次通过', '2次通过', '3次通过'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: { type: 'category', data: deviceList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', name: '%' },
series: [
{ name: '1次通过', type: 'bar', data: deviceList.map(() => Math.random() * 20 + 60) },
{ name: '2次通过', type: 'bar', data: deviceList.map(() => Math.random() * 10 + 10) },
{ name: '3次通过', type: 'bar', data: deviceList.map(() => Math.random() * 5 + 5) }
]
})
devicePassRateChart.resize()
}
})
// 2. Device Auto Rate Stats
// x: Device Name, y: Auto Rate
nextTick(() => {
if (deviceAutoRateChartRef.value) {
deviceAutoRateChart = echarts.getInstanceByDom(deviceAutoRateChartRef.value) || echarts.init(deviceAutoRateChartRef.value)
deviceAutoRateChart.setOption({
title: { text: '设备自动识别率' },
tooltip: { trigger: 'axis' },
legend: { data: ['自动识别率'], bottom: 0 },
xAxis: { type: 'category', data: deviceList, axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', name: '%' },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
series: [
{ name: '自动识别率', type: 'bar', data: deviceList.map(() => Math.random() * 15 + 75), itemStyle: { color: '#409EFF' } }
]
})
deviceAutoRateChart.resize()
}
})
}
}
const handleSearch = () => {
nextTick(() => {
updateView()
})
}
const updateView = () => {
if (isMultiDay.value) {
initMultiDayCharts()
} else {
initSingleDayCharts()
}
}
watch([isMultiDay, trendTab], () => {
nextTick(() => {
updateView()
})
})
const handleResize = () => {
passRateChart?.resize()
trendChart?.resize()
overallPassRateChart?.resize()
overallPassRateChart?.resize()
overallAutoRateChart?.resize()
devicePassRateChart?.resize()
deviceAutoRateChart?.resize()
}
onMounted(() => {
updateView()
window.addEventListener('resize', handleResize)
})
</script>
<template>
<div class="serial-stats-page">
<!-- Filter -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" :model="queryForm">
<el-form-item label="日期">
<el-date-picker
v-model="queryForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Summary Cards -->
<div class="grid grid-cols-7 gap-2 mb-4">
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">提交总次数</div><div class="text-lg font-bold">{{ summary.total }}</div></el-card>
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">自动识别占比</div><div class="text-lg font-bold text-blue-500">{{ summary.autoRate }}%</div></el-card>
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">1次通过率</div><div class="text-lg font-bold text-green-500">{{ summary.pass1 }}%</div></el-card>
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">2次通过率</div><div class="text-lg font-bold text-green-500">{{ summary.pass2 }}%</div></el-card>
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">3次内通过率</div><div class="text-lg font-bold text-green-500">{{ (summary.pass1 + summary.pass2 + summary.pass3).toFixed(1) }}%</div></el-card>
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">平均耗时</div><div class="text-lg font-bold">{{ summary.avgTime }}s</div></el-card>
<el-card shadow="never" body-style="padding: 10px"><div class="text-xs text-gray-500">平均尝试</div><div class="text-lg font-bold">{{ summary.avgAttempts }}</div></el-card>
</div>
<!-- Charts Area -->
<!-- Case 1: Single Day -->
<div v-if="!isMultiDay" class="grid grid-cols-1 gap-4 mb-4">
<el-card shadow="never">
<div ref="passRateChartRef" style="height: 350px"></div>
</el-card>
<el-card shadow="never">
<div ref="trendChartRef" style="height: 350px"></div>
</el-card>
</div>
<!-- Case 2: Multi Day -->
<el-card v-else shadow="never" class="mb-4">
<template #header>
<div class="flex items-center gap-4">
<span class="font-bold">趋势分析</span>
<el-radio-group v-model="trendTab" size="small">
<el-radio-button value="overall">整体趋势</el-radio-button>
<el-radio-button value="details">设备详情</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- Overall Trend Tab Content -->
<div v-if="trendTab === 'overall'" class="grid grid-cols-1 gap-4">
<div ref="overallPassRateTrendRef" style="height: 350px; border: 1px solid #f0f0f0; border-radius: 4px; padding: 10px;"></div>
<div ref="overallAutoRateTrendRef" style="height: 350px; border: 1px solid #f0f0f0; border-radius: 4px; padding: 10px;"></div>
</div>
<!-- Device Details Tab Content -->
<div v-if="trendTab === 'details'">
<div ref="devicePassRateChartRef" style="height: 400px; border: 1px solid #f0f0f0; border-radius: 4px; padding: 10px;" class="mb-4"></div>
<div ref="deviceAutoRateChartRef" style="height: 400px; border: 1px solid #f0f0f0; border-radius: 4px; padding: 10px;" class="mb-4"></div>
<!-- Table for Device Details -->
<el-table :data="tableData" border stripe>
<el-table-column prop="name" label="设备名称" />
<el-table-column prop="total" label="提交总次数" sortable />
<el-table-column prop="pass1" label="1次通过率" sortable />
<el-table-column prop="pass2" label="2次通过率" sortable />
<el-table-column prop="pass3" label="3次通过率" sortable />
<el-table-column prop="pass3in" label="3次内通过率" sortable />
<el-table-column prop="autoRate" label="自动识别占比" sortable />
<el-table-column prop="avgCount" label="平均识别次数" sortable />
<el-table-column prop="avgTime" label="平均耗时" sortable />
</el-table>
</div>
</el-card>
<!-- Single Day Table (Outside of multi-day card) -->
<div v-if="!isMultiDay" class="mb-4">
<el-card shadow="never">
<template #header>
<div class="flex justify-between items-center">
<span class="font-bold">设备串号明细</span>
<el-button type="success" link :icon="Download">导出表格</el-button>
</div>
</template>
<el-table :data="tableData" border stripe>
<el-table-column prop="name" label="设备名称" />
<el-table-column prop="total" label="提交总次数" sortable />
<el-table-column prop="pass1" label="1次通过率" sortable />
<el-table-column prop="pass2" label="2次通过率" sortable />
<el-table-column prop="pass3" label="3次通过率" sortable />
<el-table-column prop="pass3in" label="3次内通过率" sortable />
<el-table-column prop="autoRate" label="自动识别占比" sortable />
<el-table-column prop="avgCount" label="平均识别次数" sortable />
<el-table-column prop="avgTime" label="平均耗时" sortable />
</el-table>
</el-card>
</div>
</div>
</template>
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!