Files
vue-tickets/src/views/TicketManagement.vue
2026-05-26 14:08:13 +08:00

472 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="ticket-management">
<div class="stats-container">
<div class="stat-card total">
<div class="stat-info">
<div class="stat-label">总工单数</div>
<div class="stat-value">{{ allStats.total }}</div>
</div>
<el-icon class="stat-icon">
<Memo />
</el-icon>
</div>
<div class="stat-card pending">
<div class="stat-info">
<div class="stat-label">未分配</div>
<div class="stat-value">{{ allStats.pending }}</div>
</div>
<el-icon class="stat-icon">
<Clock />
</el-icon>
</div>
<div class="stat-card ongoing">
<div class="stat-info">
<div class="stat-label">进行中</div>
<div class="stat-value">{{ allStats.ongoing }}</div>
</div>
<el-icon class="stat-icon">
<Loading />
</el-icon>
</div>
<div class="stat-card success">
<div class="stat-info">
<div class="stat-label">已完成</div>
<div class="stat-value">{{ allStats.completed }}</div>
</div>
<el-icon class="stat-icon">
<CircleCheck />
</el-icon>
</div>
<div class="stat-card finished">
<div class="stat-info">
<div class="stat-label">已完结</div>
<div class="stat-value">{{ allStats.is_completed }}</div>
</div>
<el-icon class="stat-icon">
<Checked />
</el-icon>
</div>
</div>
<div class="main-content">
<div class="action-bar">
<div class="left">
<el-button type="primary" @click="handleAdd" :icon="Plus">新增任务</el-button>
</div>
<TicketFilter :operators="operatorsForFilter" :staffs="userStaffList" :filters="filters"
@filter-change="handleFilterChange" @reset="handleFilterReset" />
</div>
<div class="batch-bar" v-if="selectedTickets.length">
<span class="batch-info">已选 <strong>{{ selectedTickets.length }}</strong> 个工单</span>
<el-button type="warning" @click="handleBatchFinish">一键已完结</el-button>
<el-button link type="info" @click="ticketListRef.clearSelection()">取消选择</el-button>
</div>
<TicketList ref="ticketListRef" :tickets="tickets" :loading="loading" @edit="handleEdit" @delete="handleDelete"
@selection-change="val => selectedTickets = val" />
<div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
:total="pagination.total" :page-sizes="[15, 30, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</div>
<TicketForm v-model="showAddDialog" :form-data="currentTicket" :companies-with-operators="companywithoperatorsList"
:user-staffs="userStaffList" :is-editing="isEditing" @submit="handleFormSubmit" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Plus,
Memo,
Clock,
Loading,
CircleCheck,
Checked,
} from "@element-plus/icons-vue";
// API 引入
import { ticketApi } from "@/api/ticket";
// 组件引入
import TicketFilter from "@/components/ticket/TicketFilter.vue";
import TicketList from "@/components/ticket/TicketList.vue";
import TicketForm from "@/components/ticket/TicketForm.vue";
// --- 响应式状态 ---
const tickets = ref([]);
const userStaffList = ref([]);
const companywithoperatorsList = ref([]);
const loading = ref(false);
const showAddDialog = ref(false);
const isEditing = ref(false);
const currentTicket = ref({});
const selectedTickets = ref([]);
const ticketListRef = ref(null);
const operatorsForFilter = ref([]);
const allStats = reactive({
total: 0,
pending: 0,
ongoing: 0,
completed: 0,
is_completed: 0,
});
const pagination = reactive({
page: 1,
limit: 15,
total: 0,
});
const filters = reactive({
status: null,
operator_id: null,
user_id: null,
search: null,
sort_order: "desc",
});
// --- 核心数据加载方法 ---
/* 获取运营人员数据的方法*/
const fetchOperatorsForFilter = async () => {
try {
const response = await ticketApi.getOperatorsNew();
if (response.code === 200) {
// 将新的嵌套数据结构转换为原来的扁平格式
operatorsForFilter.value = response.data.flatMap(company =>
company.companyoperators.map(op => ({
id: op.id,
operator_name: op.operator_name,
company_name: company.company_name
}))
);
}
} catch (error) {
console.error("获取运营人员数据失败:", error);
}
};
/**
* 加载客户单位and运营人员
*/
const loadCompanyWithOperatorsData = async () => {
try {
const res = await ticketApi.getOperatorsNew();
if (res.code === 200) {
companywithoperatorsList.value = res.data || [];
}
} catch (error) {
console.error("获取客户及其运营人员数据失败:", error);
}
};
/**
* 加载我方员工列表
*/
const loadUserStaffData = async () => {
try {
const res = await ticketApi.getUserStaffs();
if (res.code === 200) {
userStaffList.value = res.data;
}
} catch (error) {
console.error("获取我方员工列表失败:", error);
}
};
/**
* 获取全局统计数据
*/
const initGlobalStats = async () => {
try {
const response = await ticketApi.getticketdashboardstats();
if (response.code === 200) {
allStats.total = response.data.total;
const statusCounts = response.data.status_counts || [];
statusCounts.forEach((item) => {
switch (item.status) {
case 0:
allStats.pending = item.count;
break;
case 1:
allStats.ongoing = item.count;
break;
case 2:
allStats.completed = item.count;
break;
case 3:
allStats.is_completed = item.count;
break;
}
});
}
} catch (error) {
console.error("统计全局数据失败:", error);
}
};
/**
* 加载列表数据
*/
const loadTickets = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
...filters,
};
const response = await ticketApi.getTickets(params);
if (response.code) {
tickets.value = response.data.data || [];
pagination.total = response.data.total || 0;
}
} catch (error) {
ElMessage.error("加载列表失败");
} finally {
loading.value = false;
}
};
const refreshAll = () => {
loadTickets();
initGlobalStats();
};
// --- 事件处理 ---
const handleFilterChange = (newFilters) => {
Object.assign(filters, newFilters);
pagination.page = 1;
selectedTickets.value = [];
loadTickets();
};
const handleFilterReset = () => {
Object.assign(filters, {
status: null,
operator_id: null,
user_id: null,
search: null,
sort_order: "desc",
});
pagination.page = 1;
selectedTickets.value = [];
loadTickets();
};
const handleAdd = () => {
isEditing.value = false;
currentTicket.value = {
website: "",
company_id: null,
operator_id: null,
user_id: null,
status: 0,
remark: "",
};
showAddDialog.value = true;
};
// 编辑工单
const handleEdit = (row) => {
isEditing.value = true;
currentTicket.value = { ...row };
showAddDialog.value = true;
};
// 处理工单提交
const handleFormSubmit = async (formData) => {
try {
let response;
if (isEditing.value) {
response = await ticketApi.updateTicket(currentTicket.value.id, formData);
} else {
response = await ticketApi.addTicket(formData);
}
if (response.code) {
ElMessage.success(isEditing.value ? "修改成功 ✨" : "创建成功 🚀");
showAddDialog.value = false;
refreshAll();
} else {
ElMessage.error(response.msg || "操作失败");
}
} catch (error) {
ElMessage.error("提交失败,系统异常");
}
};
// 删除工单
const handleDelete = async (id) => {
try {
await ElMessageBox.confirm("确定要删除该工单吗?此操作不可逆", "提示", {
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消",
});
const res = await ticketApi.deleteTicket(id);
if (res.code) {
ElMessage.success("删除成功");
refreshAll();
}
} catch (error) {
if (error !== "cancel") ElMessage.error("删除失败");
}
};
const handleBatchFinish = async () => {
const selected = selectedTickets.value;
const ids = selected.map((t) => t.id);
const hasUnlocked = selected.some((t) => !t.lock_time || t.lock_time === "0");
try {
await ElMessageBox.confirm(
`确认将选中的 ${ids.length} 个工单一键已完结?${hasUnlocked ? "<br/>检测到未锁定工单,将自动补齐锁定时间。" : ""}`,
"批量操作",
{
type: "warning",
confirmButtonText: "确认已完结",
dangerouslyUseHTMLString: true,
}
);
const payload = { ids, status: 3 };
if (hasUnlocked) {
payload.lock_time = String(Math.floor(Date.now() / 1000));
}
const res = await ticketApi.batchUpdateTicket(payload);
if (res.code === 200) {
ElMessage.success(res.data?.msg || `成功完结 ${ids.length} 个工单`);
ticketListRef.value.clearSelection();
refreshAll();
} else {
ElMessage.error(res.msg || "批量操作失败");
}
} catch (error) {
if (error !== "cancel") ElMessage.error("批量操作失败");
}
};
const handleSizeChange = (val) => {
pagination.limit = val;
loadTickets();
};
const handleCurrentChange = (val) => {
pagination.page = val;
loadTickets();
};
onMounted(() => {
fetchOperatorsForFilter();
loadTickets();
initGlobalStats();
loadUserStaffData();
loadCompanyWithOperatorsData();
});
</script>
<style scoped>
.ticket-management { padding: 0; min-height: 0; background: none; }
.stats-container {
display: grid;
grid-template-columns: repeat(5, minmax(140px, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.stat-card {
background: #f5f6f8;
padding: 18px 18px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-left: 4px solid transparent;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.05),
0 3px 10px rgba(0, 0, 0, 0.04);
display: flex;
justify-content: space-between;
align-items: center;
transition: box-shadow 0.2s, transform 0.2s;
user-select: none;
}
.total { border-left-color: #4f46e5; }
.pending { border-left-color: #6b7280; }
.ongoing { border-left-color: #d97706; }
.success { border-left-color: #16a34a; }
.finished { border-left-color: #0284c7; }
.stat-card:hover {
transform: translateY(-2px);
box-shadow:
0 3px 6px rgba(0, 0, 0, 0.07),
0 10px 28px rgba(0, 0, 0, 0.07);
}
.stat-icon {
font-size: 20px;
padding: 10px;
border-radius: 10px;
}
.total .stat-icon { color: #4f46e5; background: rgba(79, 70, 229, 0.12); }
.pending .stat-icon { color: #4b5563; background: rgba(75, 85, 99, 0.1); }
.ongoing .stat-icon { color: #d97706; background: rgba(217, 119, 6, 0.12); }
.success .stat-icon { color: #16a34a; background: rgba(22, 163, 74, 0.12); }
.finished .stat-icon { color: #0284c7; background: rgba(2, 132, 199, 0.12); }
.stat-label {
font-size: 12px;
color: #5b5d68;
margin-bottom: 5px;
font-weight: 600;
letter-spacing: 0.02em;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1c1c28;
font-family: var(--font-mono);
line-height: 1;
}
.main-content {
background: #f5f6f8;
padding: 22px 24px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.05),
0 6px 20px rgba(0, 0, 0, 0.05);
}
.action-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
.batch-bar {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; margin-bottom: 14px;
background: rgba(217, 119, 6, 0.1);
border: 1px solid rgba(217, 119, 6, 0.3);
border-radius: 10px;
}
.batch-info { font-size: 13px; color: #5b5d68; }
.batch-info strong { color: #92400e; font-weight: 600; }
.pagination-container { margin-top: 18px; display: flex; justify-content: flex-end; }
@media (max-width: 1400px) { .stats-container { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .stats-container { grid-template-columns: 1fr 1fr; } }
</style>