472 lines
12 KiB
Vue
472 lines
12 KiB
Vue
<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>
|