Commit 376837bd by suyuchen

feat(navigation): 添加VIP客户服务机制页面和导航菜单

- 在aside.html中添加认证管理导航菜单项(注释状态) - 在aside.html中完善导航子菜单结构 - 创建VIP客户服务机制页面,包含完整的VIP服务功能 - 添加VIP专属服务、服务通道和负责人机制展示 - 配置页面样式和响应式布局 - 添加项目配置文件.gitignore、git_toolbox_prj.xml和vcs.xml
parent 3e2035b6
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>停机问题列表 - 停机问题处理系统</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header h1 {
color: #333;
font-size: 28px;
}
.nav-menu {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-item {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s;
}
.nav-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.nav-item.active {
background: #e74c3c;
}
.card {
background: white;
border-radius: 10px;
padding: 25px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.card-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-bar {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-item {
flex: 1;
min-width: 150px;
}
.filter-item label {
display: block;
margin-bottom: 5px;
color: #555;
font-size: 14px;
}
.filter-item select,
.filter-item input {
width: 100%;
padding: 8px 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #229954;
}
.btn-sm {
padding: 6px 12px;
font-size: 14px;
}
.ticket-list {
max-height: 700px;
overflow-y: auto;
}
.ticket-item {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
transition: all 0.3s;
background: #f9f9f9;
cursor: pointer;
}
.ticket-item:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.ticket-item.urgent {
border-color: #e74c3c;
background: #ffeaea;
}
.ticket-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.ticket-id {
font-weight: bold;
color: #667eea;
font-size: 16px;
}
.ticket-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
.status-pending {
background: #f39c12;
color: white;
}
.status-processing {
background: #3498db;
color: white;
}
.status-resolved {
background: #27ae60;
color: white;
}
.status-closed {
background: #95a5a6;
color: white;
}
.priority-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
margin-left: 10px;
}
.priority-urgent {
background: #e74c3c;
color: white;
}
.priority-downtime {
background: #c0392b;
color: white;
}
.ticket-info {
margin: 10px 0;
color: #666;
font-size: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
}
.ticket-info strong {
color: #333;
}
.sla-timer {
display: inline-block;
padding: 6px 12px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
font-weight: bold;
color: #856404;
margin-top: 10px;
}
.sla-timer.warning {
background: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
.sla-timer.timeout {
background: #721c24;
border-color: #dc3545;
color: white;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.ticket-actions {
margin-top: 15px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 15px;
}
.stats-bar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-card.danger {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
}
.stat-card.warning {
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
}
.stat-value {
font-size: 32px;
font-weight: bold;
margin: 10px 0;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="header">
<div class="header-top">
<h1>🛑 停机问题列表</h1>
<div class="nav-menu">
<a href="1-停机问题列表页.html" class="nav-item active">工单列表</a>
<a href="2-创建停机工单页.html" class="nav-item">创建工单</a>
<a href="4-统计&分析页.html" class="nav-item">统计分析</a>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-bar">
<div class="stat-card danger">
<div class="stat-label">待响应</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
<div class="stat-card warning">
<div class="stat-label">处理中</div>
<div class="stat-value">{{ stats.processing }}</div>
</div>
<div class="stat-card">
<div class="stat-label">已解决</div>
<div class="stat-value">{{ stats.resolved }}</div>
</div>
<div class="stat-card success">
<div class="stat-label">已关闭</div>
<div class="stat-value">{{ stats.closed }}</div>
</div>
<div class="stat-card danger">
<div class="stat-label">SLA超时</div>
<div class="stat-value">{{ stats.timeout }}</div>
</div>
</div>
<div class="card">
<div class="card-title">
<span>停机工单列表 (共 {{ filteredTickets.length }} 条)</span>
<a href="2-创建停机工单页.html" class="btn btn-danger">
🚨 创建紧急工单
</a>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="filter-item">
<label>状态筛选</label>
<select v-model="filters.status" @change="applyFilters">
<option value="">全部状态</option>
<option value="pending">待响应</option>
<option value="processing">处理中</option>
<option value="resolved">已解决</option>
<option value="closed">已关闭</option>
</select>
</div>
<div class="filter-item">
<label>客户筛选</label>
<input type="text" v-model="filters.customer" @input="applyFilters" placeholder="输入客户名称">
</div>
<div class="filter-item">
<label>责任人筛选</label>
<input type="text" v-model="filters.assignee" @input="applyFilters" placeholder="输入责任人">
</div>
<div class="filter-item">
<label>排序方式</label>
<select v-model="filters.sortBy" @change="applyFilters">
<option value="time">按时间排序</option>
<option value="priority">按优先级排序</option>
<option value="sla">按SLA剩余时间排序</option>
</select>
</div>
</div>
<div class="ticket-list" v-if="filteredTickets.length > 0">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="ticket-item"
:class="{urgent: ticket.priority === 'downtime'}"
@click="viewTicket(ticket.id)"
>
<div class="ticket-header">
<div>
<span class="ticket-id">工单 #{{ ticket.id }}</span>
<span class="priority-badge" :class="'priority-' + ticket.priority">
{{ ticket.priority === 'downtime' ? '停机/紧急' : '紧急' }}
</span>
</div>
<span class="ticket-status" :class="'status-' + ticket.status">
{{ getStatusText(ticket.status) }}
</span>
</div>
<div class="ticket-info">
<div><strong>客户:</strong>{{ ticket.customer }}</div>
<div><strong>联系人:</strong>{{ ticket.contact }} ({{ ticket.phone }})</div>
<div><strong>设备/产线:</strong>{{ ticket.equipment }}</div>
<div><strong>责任人:</strong>{{ ticket.assignee }}</div>
<div><strong>创建时间:</strong>{{ formatTime(ticket.createTime) }}</div>
<div v-if="ticket.responseTime">
<strong>响应时间:</strong>{{ formatTime(ticket.responseTime) }}
</div>
<div v-if="ticket.resolveTime">
<strong>解决时间:</strong>{{ formatTime(ticket.resolveTime) }}
</div>
</div>
<div class="sla-timer"
:class="{
warning: ticket.slaTimeRemaining < 3600000 && ticket.slaTimeRemaining > 0,
timeout: ticket.slaTimeRemaining <= 0
}">
⏱️ SLA计时: {{ formatSlaTime(ticket.slaTimeRemaining) }}
</div>
<div class="ticket-actions" @click.stop>
<button
v-if="ticket.status === 'pending'"
@click="confirmResponse(ticket.id)"
class="btn btn-success btn-sm"
>
确认响应
</button>
<button
v-if="ticket.status === 'processing'"
@click="resolveTicket(ticket.id)"
class="btn btn-success btn-sm"
>
问题已解决
</button>
<button
@click="viewTicket(ticket.id)"
class="btn btn-primary btn-sm"
>
查看详情
</button>
<button
v-if="ticket.status === 'resolved'"
@click="closeTicket(ticket.id)"
class="btn btn-sm"
style="background: #95a5a6; color: white;"
>
关闭工单
</button>
</div>
</div>
</div>
<div class="empty-state" v-else>
<div class="empty-state-icon">📋</div>
<div>暂无符合条件的停机工单</div>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
tickets: [],
filters: {
status: '',
customer: '',
assignee: '',
sortBy: 'time'
},
slaLimit: 2 * 60 * 60 * 1000,
timer: null
}
},
computed: {
stats() {
const stats = {
pending: 0,
processing: 0,
resolved: 0,
closed: 0,
timeout: 0
};
this.tickets.forEach(ticket => {
if (ticket.status === 'pending') stats.pending++;
else if (ticket.status === 'processing') stats.processing++;
else if (ticket.status === 'resolved') stats.resolved++;
else if (ticket.status === 'closed') stats.closed++;
if (ticket.slaTimeRemaining <= 0 && ticket.status !== 'closed') stats.timeout++;
});
return stats;
},
filteredTickets() {
let result = [...this.tickets];
// 状态筛选
if (this.filters.status) {
result = result.filter(t => t.status === this.filters.status);
}
// 客户筛选
if (this.filters.customer) {
result = result.filter(t =>
t.customer.toLowerCase().includes(this.filters.customer.toLowerCase())
);
}
// 责任人筛选
if (this.filters.assignee) {
result = result.filter(t =>
t.assignee.toLowerCase().includes(this.filters.assignee.toLowerCase())
);
}
// 排序
if (this.filters.sortBy === 'time') {
result.sort((a, b) => b.createTime - a.createTime);
} else if (this.filters.sortBy === 'priority') {
result.sort((a, b) => {
if (a.priority !== b.priority) {
return a.priority === 'downtime' ? -1 : 1;
}
return b.createTime - a.createTime;
});
} else if (this.filters.sortBy === 'sla') {
result.sort((a, b) => a.slaTimeRemaining - b.slaTimeRemaining);
}
return result;
}
},
mounted() {
this.loadTickets();
this.startTimer();
},
beforeUnmount() {
if (this.timer) {
clearInterval(this.timer);
}
},
methods: {
loadTickets() {
const saved = localStorage.getItem('downtimeTickets');
if (saved) {
this.tickets = JSON.parse(saved);
this.tickets.forEach(ticket => {
if (ticket.status !== 'closed' && ticket.slaStartTime) {
const elapsed = Date.now() - ticket.slaStartTime;
ticket.slaTimeRemaining = Math.max(0, this.slaLimit - elapsed);
}
});
}
},
startTimer() {
this.timer = setInterval(() => {
this.tickets.forEach(ticket => {
if (ticket.status !== 'closed' && ticket.slaStartTime) {
const elapsed = Date.now() - ticket.slaStartTime;
ticket.slaTimeRemaining = Math.max(0, this.slaLimit - elapsed);
}
});
this.saveTickets();
}, 1000);
},
saveTickets() {
localStorage.setItem('downtimeTickets', JSON.stringify(this.tickets));
},
applyFilters() {
// 筛选逻辑在computed中处理
},
viewTicket(ticketId) {
window.location.href = `3-停机工单详情页.html?id=${ticketId}`;
},
confirmResponse(ticketId) {
const ticket = this.tickets.find(t => t.id === ticketId);
if (ticket) {
ticket.status = 'processing';
ticket.responseTime = Date.now();
this.saveTickets();
alert('已确认响应,开始处理问题');
}
},
resolveTicket(ticketId) {
const ticket = this.tickets.find(t => t.id === ticketId);
if (ticket) {
const solution = prompt('请输入解决方案:');
if (solution) {
ticket.status = 'resolved';
ticket.resolveTime = Date.now();
ticket.solution = solution;
this.saveTickets();
alert('问题已解决,等待关闭工单');
}
}
},
closeTicket(ticketId) {
const ticket = this.tickets.find(t => t.id === ticketId);
if (ticket) {
const summary = prompt('请输入复盘总结:');
if (summary !== null) {
ticket.status = 'closed';
ticket.closeTime = Date.now();
ticket.summary = summary || '无';
this.saveTickets();
alert('工单已关闭');
}
}
},
getStatusText(status) {
const statusMap = {
'pending': '待响应',
'processing': '处理中',
'resolved': '已解决',
'closed': '已关闭'
};
return statusMap[status] || status;
},
formatTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
},
formatSlaTime(remaining) {
if (remaining <= 0) return '已超时';
const hours = Math.floor(remaining / 3600000);
const minutes = Math.floor((remaining % 3600000) / 60000);
const seconds = Math.floor((remaining % 60000) / 1000);
return `${hours}小时 ${minutes}分钟 ${seconds}秒`;
}
}
}).mount('#app');
</script>
</body>
</html>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>创建停机工单 - 停机问题处理系统</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header h1 {
color: #333;
font-size: 28px;
}
.nav-menu {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-item {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s;
}
.nav-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.nav-item.active {
background: #e74c3c;
}
.card {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.card-title {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 3px solid #e74c3c;
display: flex;
align-items: center;
gap: 10px;
}
.alert-box {
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 8px;
padding: 15px;
margin-bottom: 25px;
color: #856404;
}
.alert-box strong {
color: #e74c3c;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 15px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group textarea {
resize: vertical;
min-height: 120px;
}
.required {
color: #e74c3c;
font-weight: bold;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.btn {
padding: 15px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: #e74c3c;
color: white;
font-size: 18px;
padding: 18px 40px;
width: 100%;
margin-top: 20px;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
}
.btn-secondary {
background: #95a5a6;
color: white;
margin-right: 10px;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.flow-diagram {
background: #f8f9fa;
padding: 25px;
border-radius: 8px;
margin-top: 30px;
}
.flow-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
}
.flow-step {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 12px;
background: white;
border-radius: 6px;
border-left: 4px solid #667eea;
}
.flow-step-number {
width: 30px;
height: 30px;
background: #667eea;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 15px;
flex-shrink: 0;
}
.notification-badge {
position: fixed;
top: 20px;
right: 20px;
background: #e74c3c;
color: white;
padding: 15px 25px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 999;
animation: slideIn 0.3s;
font-size: 16px;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="header">
<div class="header-top">
<h1>🛑 创建停机工单(特殊通道)</h1>
<div class="nav-menu">
<a href="1-停机问题列表页.html" class="nav-item">工单列表</a>
<a href="2-创建停机工单页.html" class="nav-item active">创建工单</a>
<a href="4-统计&分析页.html" class="nav-item">统计分析</a>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<span>🚨 紧急停机工单创建</span>
</div>
<div class="alert-box">
<strong>⚠️ 重要提示:</strong>此通道专为24小时连续生产客户的停机问题设计。
提交后将自动标记为【停机/紧急】级别,立即开始SLA计时(2小时),并自动通知相关责任人。
</div>
<form @submit.prevent="createTicket">
<div class="form-row">
<div class="form-group">
<label>客户名称 <span class="required">*</span></label>
<input type="text" v-model="newTicket.customer" required placeholder="请输入客户名称">
</div>
<div class="form-group">
<label>联系人 <span class="required">*</span></label>
<input type="text" v-model="newTicket.contact" required placeholder="请输入联系人">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>联系电话 <span class="required">*</span></label>
<input type="tel" v-model="newTicket.phone" required placeholder="请输入联系电话">
</div>
<div class="form-group">
<label>发现方式 <span class="required">*</span></label>
<select v-model="newTicket.discoveryMethod" required>
<option value="">请选择</option>
<option value="customer">客户发现</option>
<option value="internal">内部发现</option>
</select>
</div>
</div>
<div class="form-group">
<label>停机设备/产线 <span class="required">*</span></label>
<input type="text" v-model="newTicket.equipment" required placeholder="请输入停机设备或产线名称">
</div>
<div class="form-group">
<label>问题描述 <span class="required">*</span></label>
<textarea v-model="newTicket.description" required placeholder="请详细描述停机问题,包括故障现象、影响范围等"></textarea>
</div>
<div class="form-group">
<label>责任人 <span class="required">*</span></label>
<input type="text" v-model="newTicket.assignee" required placeholder="请输入责任人姓名">
</div>
<div style="display: flex; gap: 10px; margin-top: 30px;">
<button type="button" @click="resetForm" class="btn btn-danger btn-secondary" style=" white-space: nowrap;width: 200px;">
重置表单
</button>
<button type="submit" class="btn btn-danger">
🚨 提交停机工单(紧急)
</button>
</div>
</form>
<!-- 处理流程说明 -->
<div class="flow-diagram">
<div class="flow-title">📋 停机问题处理流程</div>
<div class="flow-step" v-for="(step, index) in processFlow" :key="index">
<div class="flow-step-number">{{ index + 1 }}</div>
<div>{{ step }}</div>
</div>
</div>
</div>
</div>
<!-- 通知提示 -->
<div class="notification-badge" v-if="notification.show">
{{ notification.message }}
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
newTicket: {
customer: '',
contact: '',
phone: '',
equipment: '',
description: '',
discoveryMethod: '',
assignee: ''
},
notification: {
show: false,
message: ''
},
processFlow: [
'客户/内部发现停机',
'提交停机问题工单(特殊通道)',
'系统自动标记停机级别',
'自动通知(短信/电话/IM)',
'责任人确认 & 响应(SLA计时开始)',
'问题处理 / 临时解决方案',
'恢复生产',
'关闭工单 & 复盘总结'
],
slaLimit: 2 * 60 * 60 * 1000 // 2小时SLA限制
}
},
methods: {
createTicket() {
const ticket = {
id: 'DT' + Date.now(),
customer: this.newTicket.customer,
contact: this.newTicket.contact,
phone: this.newTicket.phone,
equipment: this.newTicket.equipment,
description: this.newTicket.description,
discoveryMethod: this.newTicket.discoveryMethod,
assignee: this.newTicket.assignee,
priority: 'downtime', // 自动标记为停机/紧急
status: 'pending',
createTime: Date.now(),
responseTime: null,
resolveTime: null,
closeTime: null,
solution: '',
summary: '',
slaStartTime: Date.now(), // SLA计时开始
slaTimeRemaining: this.slaLimit
};
// 保存到localStorage
let tickets = [];
const saved = localStorage.getItem('downtimeTickets');
if (saved) {
tickets = JSON.parse(saved);
}
tickets.unshift(ticket);
localStorage.setItem('downtimeTickets', JSON.stringify(tickets));
// 发送通知
this.sendNotification(ticket);
// 显示成功消息
this.showNotification('停机工单已创建!系统已自动标记为紧急,SLA计时已开始');
// 重置表单
this.resetForm();
// 延迟跳转到列表页
setTimeout(() => {
if (confirm('工单创建成功!是否跳转到工单列表?')) {
window.location.href = '1-停机问题列表页.html';
}
}, 2000);
},
resetForm() {
this.newTicket = {
customer: '',
contact: '',
phone: '',
equipment: '',
description: '',
discoveryMethod: '',
assignee: ''
};
},
sendNotification(ticket) {
// 模拟发送通知(短信/电话/IM)
console.log('发送通知:', {
短信: `【停机警报】工单${ticket.id}已创建,客户:${ticket.customer},责任人:${ticket.assignee}`,
电话: `紧急通知:${ticket.assignee},请立即处理停机工单${ticket.id}`,
IM: `@${ticket.assignee} 新的停机工单 #${ticket.id} 需要您立即处理`
});
},
showNotification(message) {
this.notification.message = message;
this.notification.show = true;
setTimeout(() => {
this.notification.show = false;
}, 5000);
}
}
}).mount('#app');
</script>
</body>
</html>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工单详情 - 停机问题处理系统</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header h1 {
color: #333;
font-size: 28px;
}
.nav-menu {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-item {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s;
}
.nav-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.card {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.ticket-header-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 25px;
}
.info-item {
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.info-item strong {
display: block;
color: #667eea;
margin-bottom: 8px;
font-size: 14px;
}
.info-item span {
color: #333;
font-size: 16px;
}
.priority-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
}
.priority-downtime {
background: #c0392b;
color: white;
}
.ticket-status {
padding: 6px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.status-pending {
background: #f39c12;
color: white;
}
.status-processing {
background: #3498db;
color: white;
}
.status-resolved {
background: #27ae60;
color: white;
}
.status-closed {
background: #95a5a6;
color: white;
}
.sla-timer {
display: inline-block;
padding: 10px 20px;
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 6px;
font-weight: bold;
color: #856404;
font-size: 18px;
margin: 20px 0;
}
.sla-timer.warning {
background: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
.sla-timer.timeout {
background: #721c24;
border-color: #dc3545;
color: white;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h3 {
color: #333;
margin-bottom: 15px;
font-size: 18px;
padding-bottom: 10px;
border-bottom: 1px solid #e0e0e0;
}
.detail-content {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
color: #555;
line-height: 1.8;
white-space: pre-wrap;
}
.timeline {
position: relative;
padding-left: 30px;
}
.timeline::before {
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: #e0e0e0;
}
.timeline-item {
position: relative;
margin-bottom: 20px;
padding-left: 20px;
}
.timeline-item::before {
content: '';
position: absolute;
left: -25px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #667eea;
border: 2px solid white;
box-shadow: 0 0 0 2px #667eea;
}
.timeline-item.completed::before {
background: #27ae60;
box-shadow: 0 0 0 2px #27ae60;
}
.timeline-content {
background: #f8f9fa;
padding: 12px 15px;
border-radius: 6px;
}
.timeline-title {
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.timeline-time {
color: #999;
font-size: 12px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s;
font-weight: 500;
margin-right: 10px;
margin-bottom: 10px;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #229954;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 15px;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="header">
<div class="header-top">
<h1>🛑 停机工单详情</h1>
<div class="nav-menu">
<a href="1-停机问题列表页.html" class="nav-item">工单列表</a>
<a href="2-创建停机工单页.html" class="nav-item">创建工单</a>
<a href="4-统计&分析页.html" class="nav-item">统计分析</a>
</div>
</div>
</div>
<div v-if="ticket.id">
<!-- 工单基本信息 -->
<div class="card">
<div class="card-title">工单基本信息</div>
<div class="ticket-header-info">
<div class="info-item">
<strong>工单编号</strong>
<span>{{ ticket.id }}</span>
</div>
<div class="info-item">
<strong>优先级</strong>
<span class="priority-badge priority-downtime">停机/紧急</span>
</div>
<div class="info-item">
<strong>当前状态</strong>
<span class="ticket-status" :class="'status-' + ticket.status">
{{ getStatusText(ticket.status) }}
</span>
</div>
</div>
<div class="sla-timer"
:class="{
warning: ticket.slaTimeRemaining < 3600000 && ticket.slaTimeRemaining > 0,
timeout: ticket.slaTimeRemaining <= 0
}">
⏱️ SLA计时: {{ formatSlaTime(ticket.slaTimeRemaining) }}
</div>
<div class="ticket-header-info">
<div class="info-item">
<strong>客户名称</strong>
<span>{{ ticket.customer }}</span>
</div>
<div class="info-item">
<strong>联系人</strong>
<span>{{ ticket.contact }}</span>
</div>
<div class="info-item">
<strong>联系电话</strong>
<span>{{ ticket.phone }}</span>
</div>
<div class="info-item">
<strong>停机设备/产线</strong>
<span>{{ ticket.equipment }}</span>
</div>
<div class="info-item">
<strong>发现方式</strong>
<span>{{ ticket.discoveryMethod === 'customer' ? '客户发现' : '内部发现' }}</span>
</div>
<div class="info-item">
<strong>责任人</strong>
<span>{{ ticket.assignee }}</span>
</div>
</div>
</div>
<!-- 问题描述 -->
<div class="card">
<div class="card-title">问题描述</div>
<div class="detail-section">
<div class="detail-content">{{ ticket.description }}</div>
</div>
</div>
<!-- 处理流程时间线 -->
<div class="card">
<div class="card-title">处理流程时间线</div>
<div class="timeline">
<div class="timeline-item" :class="{completed: ticket.createTime}">
<div class="timeline-content">
<div class="timeline-title">工单创建</div>
<div class="timeline-time">{{ formatTime(ticket.createTime) }}</div>
</div>
</div>
<div class="timeline-item" :class="{completed: ticket.responseTime}">
<div class="timeline-content">
<div class="timeline-title">责任人响应确认</div>
<div class="timeline-time" v-if="ticket.responseTime">{{ formatTime(ticket.responseTime) }}</div>
<div class="timeline-time" v-else>待响应</div>
</div>
</div>
<div class="timeline-item" :class="{completed: ticket.resolveTime}">
<div class="timeline-content">
<div class="timeline-title">问题解决</div>
<div class="timeline-time" v-if="ticket.resolveTime">{{ formatTime(ticket.resolveTime) }}</div>
<div class="timeline-time" v-else>处理中</div>
</div>
</div>
<div class="timeline-item" :class="{completed: ticket.closeTime}">
<div class="timeline-content">
<div class="timeline-title">工单关闭</div>
<div class="timeline-time" v-if="ticket.closeTime">{{ formatTime(ticket.closeTime) }}</div>
<div class="timeline-time" v-else>未关闭</div>
</div>
</div>
</div>
</div>
<!-- 解决方案 -->
<div class="card" v-if="ticket.solution">
<div class="card-title">解决方案</div>
<div class="detail-section">
<div class="detail-content">{{ ticket.solution }}</div>
</div>
</div>
<!-- 复盘总结 -->
<div class="card" v-if="ticket.summary">
<div class="card-title">复盘总结</div>
<div class="detail-section">
<div class="detail-content">{{ ticket.summary }}</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card">
<div class="card-title">操作</div>
<div>
<button
v-if="ticket.status === 'pending'"
@click="confirmResponse"
class="btn btn-success"
>
确认响应
</button>
<button
v-if="ticket.status === 'processing'"
@click="resolveTicket"
class="btn btn-success"
>
问题已解决
</button>
<button
v-if="ticket.status === 'resolved'"
@click="closeTicket"
class="btn btn-success"
>
关闭工单
</button>
<a href="1-停机问题列表页.html" class="btn btn-primary">
返回列表
</a>
</div>
</div>
</div>
<div class="card" v-else>
<div class="empty-state">
<div class="empty-state-icon"></div>
<div>未找到工单信息</div>
<a href="1-停机问题列表页.html" class="btn btn-primary" style="margin-top: 20px; display: inline-block;">
返回列表
</a>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
ticket: {},
ticketId: null,
slaLimit: 2 * 60 * 60 * 1000,
timer: null
}
},
mounted() {
// 从URL获取工单ID
const urlParams = new URLSearchParams(window.location.search);
this.ticketId = urlParams.get('id');
this.loadTicket();
if (this.ticket.id && this.ticket.status !== 'closed') {
this.startTimer();
}
},
beforeUnmount() {
if (this.timer) {
clearInterval(this.timer);
}
},
methods: {
loadTicket() {
const saved = localStorage.getItem('downtimeTickets');
if (saved && this.ticketId) {
const tickets = JSON.parse(saved);
this.ticket = tickets.find(t => t.id === this.ticketId) || {};
if (this.ticket.slaStartTime && this.ticket.status !== 'closed') {
const elapsed = Date.now() - this.ticket.slaStartTime;
this.ticket.slaTimeRemaining = Math.max(0, this.slaLimit - elapsed);
}
}
},
startTimer() {
this.timer = setInterval(() => {
if (this.ticket.status !== 'closed' && this.ticket.slaStartTime) {
const elapsed = Date.now() - this.ticket.slaStartTime;
this.ticket.slaTimeRemaining = Math.max(0, this.slaLimit - elapsed);
}
}, 1000);
},
saveTickets() {
const saved = localStorage.getItem('downtimeTickets');
if (saved) {
const tickets = JSON.parse(saved);
const index = tickets.findIndex(t => t.id === this.ticket.id);
if (index !== -1) {
tickets[index] = this.ticket;
localStorage.setItem('downtimeTickets', JSON.stringify(tickets));
}
}
},
confirmResponse() {
this.ticket.status = 'processing';
this.ticket.responseTime = Date.now();
this.saveTickets();
alert('已确认响应,开始处理问题');
},
resolveTicket() {
const solution = prompt('请输入解决方案:');
if (solution) {
this.ticket.status = 'resolved';
this.ticket.resolveTime = Date.now();
this.ticket.solution = solution;
this.saveTickets();
alert('问题已解决,等待关闭工单');
}
},
closeTicket() {
const summary = prompt('请输入复盘总结:');
if (summary !== null) {
this.ticket.status = 'closed';
this.ticket.closeTime = Date.now();
this.ticket.summary = summary || '无';
this.saveTickets();
alert('工单已关闭');
}
},
getStatusText(status) {
const statusMap = {
'pending': '待响应',
'processing': '处理中',
'resolved': '已解决',
'closed': '已关闭'
};
return statusMap[status] || status;
},
formatTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
},
formatSlaTime(remaining) {
if (remaining <= 0) return '已超时';
const hours = Math.floor(remaining / 3600000);
const minutes = Math.floor((remaining % 3600000) / 60000);
const seconds = Math.floor((remaining % 60000) / 1000);
return `${hours}小时 ${minutes}分钟 ${seconds}秒`;
}
}
}).mount('#app');
</script>
</body>
</html>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>统计&分析 - 停机问题处理系统</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header h1 {
color: #333;
font-size: 28px;
}
.nav-menu {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-item {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s;
}
.nav-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.nav-item.active {
background: #e74c3c;
}
.card {
background: white;
border-radius: 10px;
padding: 25px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 10px;
text-align: center;
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card.danger {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
.stat-card.warning {
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
}
.stat-card.info {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
}
.stat-value {
font-size: 36px;
font-weight: bold;
margin: 15px 0;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
.stat-change {
font-size: 12px;
margin-top: 10px;
opacity: 0.8;
}
.chart-container {
position: relative;
height: 300px;
margin: 20px 0;
}
.chart-container.large {
height: 400px;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.data-table th {
background: #f8f9fa;
font-weight: bold;
color: #333;
}
.data-table tr:hover {
background: #f8f9fa;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #27ae60;
color: white;
}
.badge-warning {
background: #f39c12;
color: white;
}
.badge-danger {
background: #e74c3c;
color: white;
}
.filter-bar {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-item {
flex: 1;
min-width: 150px;
}
.filter-item label {
display: block;
margin-bottom: 5px;
color: #555;
font-size: 14px;
}
.filter-item select,
.filter-item input {
width: 100%;
padding: 8px 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
font-weight: 500;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 768px) {
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 15px;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="header">
<div class="header-top">
<h1>📊 统计&分析</h1>
<div class="nav-menu">
<a href="1-停机问题列表页.html" class="nav-item">工单列表</a>
<a href="2-创建停机工单页.html" class="nav-item">创建工单</a>
<a href="4-统计&分析页.html" class="nav-item active">统计分析</a>
</div>
</div>
</div>
<!-- 核心指标统计 -->
<div class="stats-grid">
<div class="stat-card danger">
<div class="stat-label">总工单数</div>
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-change">全部停机工单</div>
</div>
<div class="stat-card warning">
<div class="stat-label">待处理</div>
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-change">待响应 + 处理中</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ stats.resolvedRate }}%</div>
<div class="stat-label">解决率</div>
<div class="stat-change">已解决 / 总工单</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ stats.avgResponseTime }}</div>
<div class="stat-label">平均响应时间</div>
<div class="stat-change">分钟</div>
</div>
<div class="stat-card danger">
<div class="stat-value">{{ stats.avgResolveTime }}</div>
<div class="stat-label">平均解决时间</div>
<div class="stat-change">分钟</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ stats.timeoutCount }}</div>
<div class="stat-label">SLA超时数</div>
<div class="stat-change">当前超时工单</div>
</div>
</div>
<!-- 时间筛选 -->
<div class="card">
<div class="filter-bar">
<div class="filter-item">
<label>时间范围</label>
<select v-model="timeRange" @change="updateStats">
<option value="all">全部时间</option>
<option value="today">今天</option>
<option value="week">最近7天</option>
<option value="month">最近30天</option>
<option value="quarter">最近3个月</option>
</select>
</div>
<div class="filter-item">
<label>开始日期</label>
<input type="date" v-model="startDate" @change="updateStats">
</div>
<div class="filter-item">
<label>结束日期</label>
<input type="date" v-model="endDate" @change="updateStats">
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="grid-2">
<!-- 工单状态分布 -->
<div class="card">
<div class="card-title">工单状态分布</div>
<div class="chart-container">
<canvas id="statusChart"></canvas>
</div>
</div>
<!-- 工单趋势 -->
<div class="card">
<div class="card-title">工单创建趋势</div>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
<!-- 响应时间分析 -->
<div class="card">
<div class="card-title">响应时间分析</div>
<div class="chart-container large">
<canvas id="responseTimeChart"></canvas>
</div>
</div>
<!-- 责任人统计 -->
<div class="card">
<div class="card-title">责任人处理统计</div>
<div class="chart-container large">
<canvas id="assigneeChart"></canvas>
</div>
</div>
<!-- 客户统计 -->
<div class="card">
<div class="card-title">客户停机统计</div>
<table class="data-table">
<thead>
<tr>
<th>客户名称</th>
<th>工单数量</th>
<th>平均响应时间</th>
<th>平均解决时间</th>
<th>超时次数</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in customerStats" :key="index">
<td>{{ item.customer }}</td>
<td>{{ item.count }}</td>
<td>{{ item.avgResponse }} 分钟</td>
<td>{{ item.avgResolve }} 分钟</td>
<td>
<span class="badge" :class="item.timeout > 0 ? 'badge-danger' : 'badge-success'">
{{ item.timeout }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 详细数据表格 -->
<div class="card">
<div class="card-title">工单详细数据</div>
<table class="data-table">
<thead>
<tr>
<th>工单编号</th>
<th>客户</th>
<th>责任人</th>
<th>状态</th>
<th>创建时间</th>
<th>响应时间</th>
<th>解决时间</th>
<th>SLA状态</th>
</tr>
</thead>
<tbody>
<tr v-for="ticket in filteredTickets" :key="ticket.id">
<td>
<a :href="'3-停机工单详情页.html?id=' + ticket.id" style="color: #667eea; text-decoration: none;">
{{ ticket.id }}
</a>
</td>
<td>{{ ticket.customer }}</td>
<td>{{ ticket.assignee }}</td>
<td>
<span class="badge" :class="getStatusBadgeClass(ticket.status)">
{{ getStatusText(ticket.status) }}
</span>
</td>
<td>{{ formatTime(ticket.createTime) }}</td>
<td>{{ ticket.responseTime ? formatTime(ticket.responseTime) : '-' }}</td>
<td>{{ ticket.resolveTime ? formatTime(ticket.resolveTime) : '-' }}</td>
<td>
<span class="badge" :class="ticket.slaTimeRemaining <= 0 ? 'badge-danger' : 'badge-success'">
{{ ticket.slaTimeRemaining <= 0 ? '超时' : '正常' }}
</span>
</td>
</tr>
</tbody>
</table>
<div class="empty-state" v-if="filteredTickets.length === 0">
<div class="empty-state-icon">📊</div>
<div>暂无数据</div>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
tickets: [],
timeRange: 'all',
startDate: '',
endDate: '',
charts: {}
}
},
computed: {
filteredTickets() {
let result = [...this.tickets];
// 时间筛选
if (this.timeRange === 'today') {
const today = new Date();
today.setHours(0, 0, 0, 0);
result = result.filter(t => t.createTime >= today.getTime());
} else if (this.timeRange === 'week') {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
result = result.filter(t => t.createTime >= weekAgo);
} else if (this.timeRange === 'month') {
const monthAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
result = result.filter(t => t.createTime >= monthAgo);
} else if (this.timeRange === 'quarter') {
const quarterAgo = Date.now() - 90 * 24 * 60 * 60 * 1000;
result = result.filter(t => t.createTime >= quarterAgo);
}
// 日期范围筛选
if (this.startDate) {
const start = new Date(this.startDate).getTime();
result = result.filter(t => t.createTime >= start);
}
if (this.endDate) {
const end = new Date(this.endDate).getTime() + 24 * 60 * 60 * 1000;
result = result.filter(t => t.createTime < end);
}
return result;
},
stats() {
const filtered = this.filteredTickets;
const total = filtered.length;
const pending = filtered.filter(t => t.status === 'pending' || t.status === 'processing').length;
const resolved = filtered.filter(t => t.status === 'resolved' || t.status === 'closed').length;
const resolvedRate = total > 0 ? Math.round((resolved / total) * 100) : 0;
// 计算平均响应时间
const responseTimes = filtered
.filter(t => t.responseTime)
.map(t => (t.responseTime - t.createTime) / 60000);
const avgResponseTime = responseTimes.length > 0
? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length)
: 0;
// 计算平均解决时间
const resolveTimes = filtered
.filter(t => t.resolveTime)
.map(t => (t.resolveTime - t.createTime) / 60000);
const avgResolveTime = resolveTimes.length > 0
? Math.round(resolveTimes.reduce((a, b) => a + b, 0) / resolveTimes.length)
: 0;
// SLA超时数
const timeoutCount = filtered.filter(t =>
t.slaTimeRemaining <= 0 && t.status !== 'closed'
).length;
return {
total,
pending,
resolved,
resolvedRate,
avgResponseTime,
avgResolveTime,
timeoutCount
};
},
customerStats() {
const customerMap = {};
this.filteredTickets.forEach(ticket => {
if (!customerMap[ticket.customer]) {
customerMap[ticket.customer] = {
customer: ticket.customer,
count: 0,
responseTimes: [],
resolveTimes: [],
timeout: 0
};
}
customerMap[ticket.customer].count++;
if (ticket.responseTime) {
customerMap[ticket.customer].responseTimes.push(
(ticket.responseTime - ticket.createTime) / 60000
);
}
if (ticket.resolveTime) {
customerMap[ticket.customer].resolveTimes.push(
(ticket.resolveTime - ticket.createTime) / 60000
);
}
if (ticket.slaTimeRemaining <= 0 && ticket.status !== 'closed') {
customerMap[ticket.customer].timeout++;
}
});
return Object.values(customerMap).map(item => ({
customer: item.customer,
count: item.count,
avgResponse: item.responseTimes.length > 0
? Math.round(item.responseTimes.reduce((a, b) => a + b, 0) / item.responseTimes.length)
: 0,
avgResolve: item.resolveTimes.length > 0
? Math.round(item.resolveTimes.reduce((a, b) => a + b, 0) / item.resolveTimes.length)
: 0,
timeout: item.timeout
})).sort((a, b) => b.count - a.count);
}
},
mounted() {
this.loadTickets();
this.$nextTick(() => {
this.initCharts();
});
},
watch: {
filteredTickets: {
handler() {
this.$nextTick(() => {
this.updateCharts();
});
},
deep: true
}
},
methods: {
loadTickets() {
const saved = localStorage.getItem('downtimeTickets');
if (saved) {
this.tickets = JSON.parse(saved);
}
},
updateStats() {
this.$nextTick(() => {
this.updateCharts();
});
},
initCharts() {
// 状态分布饼图
const statusCtx = document.getElementById('statusChart');
if (statusCtx) {
this.charts.status = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['待响应', '处理中', '已解决', '已关闭'],
datasets: [{
data: [0, 0, 0, 0],
backgroundColor: [
'#f39c12',
'#3498db',
'#27ae60',
'#95a5a6'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
// 趋势折线图
const trendCtx = document.getElementById('trendChart');
if (trendCtx) {
this.charts.trend = new Chart(trendCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: '工单数量',
data: [],
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// 响应时间柱状图
const responseCtx = document.getElementById('responseTimeChart');
if (responseCtx) {
this.charts.responseTime = new Chart(responseCtx, {
type: 'bar',
data: {
labels: [],
datasets: [{
label: '响应时间(分钟)',
data: [],
backgroundColor: '#3498db'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// 责任人统计
const assigneeCtx = document.getElementById('assigneeChart');
if (assigneeCtx) {
this.charts.assignee = new Chart(assigneeCtx, {
type: 'bar',
data: {
labels: [],
datasets: [{
label: '处理工单数',
data: [],
backgroundColor: '#27ae60'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
this.updateCharts();
},
updateCharts() {
const filtered = this.filteredTickets;
// 更新状态分布
if (this.charts.status) {
this.charts.status.data.datasets[0].data = [
filtered.filter(t => t.status === 'pending').length,
filtered.filter(t => t.status === 'processing').length,
filtered.filter(t => t.status === 'resolved').length,
filtered.filter(t => t.status === 'closed').length
];
this.charts.status.update();
}
// 更新趋势图(按日期统计)
if (this.charts.trend) {
const dateMap = {};
filtered.forEach(ticket => {
const date = new Date(ticket.createTime).toLocaleDateString('zh-CN');
dateMap[date] = (dateMap[date] || 0) + 1;
});
const dates = Object.keys(dateMap).sort();
this.charts.trend.data.labels = dates;
this.charts.trend.data.datasets[0].data = dates.map(d => dateMap[d]);
this.charts.trend.update();
}
// 更新响应时间(按责任人)
if (this.charts.responseTime) {
const assigneeMap = {};
filtered.forEach(ticket => {
if (ticket.responseTime) {
if (!assigneeMap[ticket.assignee]) {
assigneeMap[ticket.assignee] = [];
}
assigneeMap[ticket.assignee].push(
(ticket.responseTime - ticket.createTime) / 60000
);
}
});
const assignees = Object.keys(assigneeMap);
this.charts.responseTime.data.labels = assignees;
this.charts.responseTime.data.datasets[0].data = assignees.map(a => {
const times = assigneeMap[a];
return Math.round(times.reduce((x, y) => x + y, 0) / times.length);
});
this.charts.responseTime.update();
}
// 更新责任人统计
if (this.charts.assignee) {
const assigneeCount = {};
filtered.forEach(ticket => {
assigneeCount[ticket.assignee] = (assigneeCount[ticket.assignee] || 0) + 1;
});
const assignees = Object.keys(assigneeCount).sort((a, b) =>
assigneeCount[b] - assigneeCount[a]
).slice(0, 10);
this.charts.assignee.data.labels = assignees;
this.charts.assignee.data.datasets[0].data = assignees.map(a => assigneeCount[a]);
this.charts.assignee.update();
}
},
getStatusText(status) {
const statusMap = {
'pending': '待响应',
'processing': '处理中',
'resolved': '已解决',
'closed': '已关闭'
};
return statusMap[status] || status;
},
getStatusBadgeClass(status) {
const classMap = {
'pending': 'badge-warning',
'processing': 'badge-info',
'resolved': 'badge-success',
'closed': 'badge-secondary'
};
return classMap[status] || '';
},
formatTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
}
}
}).mount('#app');
</script>
</body>
</html>
<!DOCTYPE html> <!DOCTYPE html>
...@@ -963,3 +963,4 @@ ...@@ -963,3 +963,4 @@
</body> </body>
</html> </html>
<!DOCTYPE html> <!DOCTYPE html>
...@@ -823,3 +823,4 @@ ...@@ -823,3 +823,4 @@
</body> </body>
</html> </html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment