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 { 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>
/// <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!