减少冗余代码

This commit is contained in:
2026-05-14 15:41:00 +08:00
parent ae4cf4540b
commit fa0c718c8b
15 changed files with 802 additions and 439 deletions

View File

@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"

View File

@@ -1,78 +1,80 @@
import request from '@/utils/request' import { adminRequest, indexRequest } from '@/utils/request'
// ==================== 前台接口(/api/index) ====================
export const userApi = { export const userApi = {
// 登录接口
login(username, password) { login(username, password) {
// 这里的 data 会被 axios 放在请求体 (Body) 中发送 return indexRequest.post('/login', { username, password })
return request.post('/login', {
username,
password
})
}, },
// 退出登录
logout() { logout() {
return request.post('/logout') return indexRequest.post('/logout')
} }
} }
// ==================== 后台接口(/api/admin) ====================
export const ticketApi = { export const ticketApi = {
//获取工单各个完成状态的统计数量 // 获取工单各完成状态的统计数量
getticketsstatuscount(){ getticketdashboardstats() {
return request.get('/getticketstatuscount') return adminRequest.get('/getticketdashboardstats')
}, },
// 获取工单列表 // 获取工单列表
getTickets(params) { getTickets(params) {
return request.get('/ticketsfind', { params }) return adminRequest.get('/ticketsfind', { params })
}, },
// 新增工单 // 新增工单
addTicket(data) { addTicket(data) {
return request.post('/addticket', data) return adminRequest.post('/addticket', data)
}, },
// 更新工单 // 更新工单
updateTicket(id, data) { updateTicket(id, data) {
return request.put(`/ticket/${id}`, data) return adminRequest.put(`/ticket/${id}`, data)
}, },
// 删除工单 // 删除工单
deleteTicket(id) { deleteTicket(id) {
return request.delete(`/ticket/${id}`) return adminRequest.delete(`/ticket/${id}`)
}, },
// 获取运营人员列表 // 获取运营人员列表(按公司分组)
getOperators() { getOperatorsNew() {
return request.get('/operators') return adminRequest.get('/getOperatorList')
}, },
// 获取员工列表 // 获取员工列表
getStaffs(params) { getUserStaffs() {
return request.get('/getStaffList', { params }) return adminRequest.get('/getUserStaffList')
} },
// 批量更新工单
batchUpdateTicket(data) {
return adminRequest.put('/tickets/batch-update', data)
},
} }
export const customerApi = { export const customerApi = {
getCustomers() { getCustomers() {
return request.get('/customers') return adminRequest.get('/customers')
}, },
// 添加客户,data 结构应为 { company_name: 'xxx' } // 添加客户,data 结构应为 { company_name: 'xxx' }
addCustomerName(data) { addCustomerName(data) {
return request.post('/addcustomername', data) return adminRequest.post('/addcustomername', data)
}, },
addEmployee(data) { addEmployee(data) {
return request.post('/addemployee', data) // 对应后端新增员工 return adminRequest.post('/addemployee', data)
}, },
deleteEmployee(empId) {
return request.delete(`/deleteemployee/${empId}`) // 对应后端删除员工
},
deleteCustomer(customerId) {
return request.delete(`/deletecustomer/${customerId}`) // 删除客户单位
}
deleteEmployee(empId) {
return adminRequest.delete(`/deleteemployee/${empId}`)
},
deleteCustomer(customerId) {
return adminRequest.delete(`/deletecustomer/${customerId}`)
}
} }

View File

@@ -15,24 +15,26 @@
<div class="input-group-row"> <div class="input-group-row">
<el-form-item label="运营人员"> <el-form-item label="运营人员">
<el-select v-model="localFilters.operator_name" placeholder="全部运营人员" clearable filterable <el-select v-model="localFilters.operator_id" placeholder="全部运营人员" clearable filterable
class="wide-select" @change="handleSearch"> class="wide-select" @change="handleSearch">
<el-option v-for="op in operators" :key="op.id" :label="`${op.name} (${op.company})`" <el-option-group v-for="group in groupedOperators" :key="group.company_name"
:value="op.name" /> :label="group.company_name">
<el-option v-for="op in group.operators" :key="op.id" :label="`${op.operator_name}`" :value="op.id" />
</el-option-group>
</el-select> </el-select>
</el-form-item> </el-form-item>
<div class="divider-wrapper">/</div> <div class="divider-wrapper">/</div>
<el-form-item label="我方负责人"> <el-form-item label="我方牛马">
<el-select v-model="localFilters.staff_id" placeholder="全部负责人" clearable filterable class="wide-select" <el-select v-model="localFilters.user_id" placeholder="全部牛马" clearable filterable class="wide-select"
@change="handleSearch"> @change="handleSearch">
<el-option v-for="staff in staffs" :key="staff.id" :label="staff.name" :value="staff.id" /> <el-option v-for="staff in staffs" :key="staff.id" :label="staff.username" :value="staff.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="关键词" class="search-keyword"> <el-form-item label="关键词" class="search-keyword">
<el-input v-model="localFilters.search" placeholder="搜索网站、运营、备注..." clearable @keyup.enter="handleSearch" <el-input v-model="localFilters.search" placeholder="搜索网站、备注" clearable @keyup.enter="handleSearch"
class="wide-search-input"> class="wide-search-input">
<template #prefix> <template #prefix>
<el-icon> <el-icon>
@@ -66,7 +68,7 @@
</template> </template>
<script setup> <script setup>
import { reactive, watch } from "vue"; import { reactive, watch, computed } from "vue";
import { Search, Sort } from "@element-plus/icons-vue"; import { Search, Sort } from "@element-plus/icons-vue";
const props = defineProps({ const props = defineProps({
@@ -75,9 +77,51 @@ const props = defineProps({
staffs: { type: Array, default: () => [] }, staffs: { type: Array, default: () => [] },
}); });
const emit = defineEmits(["filter-change", "reset"]); const emit = defineEmits(["filter-change", "reset"]); // 先声明事件,后使用
const localFilters = reactive({ ...props.filters }); // ...(浅拷贝)
// 公司优先级排序(可以在这里调整顺序)
const getCompanyPriority = (companyName) => {
const priority = {
第一客户: 1,
第三客户: 2,
流玖电商: 3,
};
return priority[companyName] || 999;
};
// 分组并排序的运营人员
const groupedOperators = computed(() => {
// 1. 先按公司分组
const groups = {};
props.operators.forEach((op) => {
if (!groups[op.company_name]) {
groups[op.company_name] = [];
}
groups[op.company_name].push(op);
});
// 2. 对公司分组进行排序(按优先级 + 名称)
const sortedGroups = Object.entries(groups)
.map(([company_name, operators]) => ({
company_name,
operators: [...operators].sort((a, b) =>
a.operator_name.localeCompare(b.operator_name, "zh-CN")
),
}))
.sort((a, b) => {
const priorityA = getCompanyPriority(a.company_name);
const priorityB = getCompanyPriority(b.company_name);
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
return a.company_name.localeCompare(b.company_name, "zh-CN");
});
return sortedGroups;
});
const localFilters = reactive({ ...props.filters });
watch( watch(
() => props.filters, () => props.filters,
@@ -197,4 +241,12 @@ const setSort = (order) => {
width: 100%; width: 100%;
} }
} }
/* 运营人员分组标题样式 */
:deep(.el-select-group__title) {
background-color: #f5f7fa;
font-weight: 600;
color: #303133;
padding-left: 12px;
}
</style> </style>

View File

@@ -3,9 +3,9 @@
destroy-on-close @close="handleClose"> destroy-on-close @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="compact-form"> <el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="compact-form">
<div class="form-section"> <div class="form-section">
<div class="section-title">业务基础信息</div> <div class="section-title">基础信息</div>
<el-form-item label="网站地址" prop="website"> <el-form-item label="网站地址" prop="website">
<el-input v-model="form.website" placeholder="请输入网站 URL" clearable> <el-input v-model="form.website" placeholder="输入URL" clearable>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Link /> <Link />
@@ -16,34 +16,34 @@
</div> </div>
<div class="form-section people-grid"> <div class="form-section people-grid">
<div class="section-title column-full">权责分配</div> <div class="section-title column-full">人员选择</div>
<el-form-item label="客户单位 (外方)" prop="customer_id" class="client-item"> <el-form-item label="客户单位" prop="company_id" class="client-item">
<el-select v-model="form.customer_id" placeholder="请选择单位" style="width: 100%" filterable <el-select v-model="form.company_id" placeholder="请选择单位" style="width: 100%" filterable
@change="handleCustomerChange"> @change="handleCustomerChange">
<el-option v-for="item in customers" :key="item.customer_id" :label="item.company_name" <el-option v-for="item in companiesWithOperators.filter(c => c.id !== 0)" :key="item.id" :label="item.company_name"
:value="item.customer_id" /> :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="运营人员 (外方)" prop="operator" class="client-item"> <el-form-item label="运营人员" prop="operator_id" class="client-item">
<el-select v-model="form.operator" placeholder="请选择人员" style="width: 100%" filterable <el-select v-model="form.operator_id" placeholder="请选择人员" style="width: 100%" filterable
:disabled="!form.customer_id"> :disabled="!form.company_id">
<el-option v-for="item in filteredEmployees" :key="item.employee_id" :label="item.employee_name" <el-option v-for="item in (companiesWithOperators.find(c => c.id === form.company_id)?.companyoperators || []).filter(op => op.id !== 0)" :key="item.id" :label="item.operator_name"
:value="item.employee_name" /> :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="isEditing" label="负责人 (我方)" prop="staff_id" class="owner-item column-full"> <el-form-item label="我方牛马" prop="user_id" class="owner-item column-full">
<el-select v-model="form.staff_id" placeholder="请选择我方负责人" style="width: 100%" filterable> <el-select v-model="form.user_id" placeholder="请选择牛马" style="width: 100%" filterable>
<el-option v-for="item in staffs" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in userStaffs" :key="item.id" :label="item.username" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
<div class="form-section"> <div class="form-section">
<el-form-item label="当前进度"> <el-form-item label="进度">
<el-radio-group v-model="form.is_complete" class="modern-radio-group"> <el-radio-group v-model="form.status" class="modern-radio-group">
<el-radio-button label="未分配">未分配</el-radio-button> <el-radio-button label="未分配">未分配</el-radio-button>
<el-radio-button label="进行中">进行中</el-radio-button> <el-radio-button label="进行中">进行中</el-radio-button>
<el-radio-button label="已完成">已完成</el-radio-button> <el-radio-button label="已完成">已完成</el-radio-button>
@@ -51,8 +51,8 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="备注说明" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写相关的补充信息或注意事项..." resize="none" /> <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" />
</el-form-item> </el-form-item>
</div> </div>
</el-form> </el-form>
@@ -77,10 +77,9 @@ const emit = defineEmits(["update:modelValue", "submit"]);
const props = defineProps({ const props = defineProps({
modelValue: Boolean, modelValue: Boolean,
formData: Object, formData: Object,
customers: Array, userStaffs: Array,
customerEmployees: Array,
staffs: Array,
isEditing: Boolean, isEditing: Boolean,
companiesWithOperators: Array,
}); });
// --- 响应式变量 --- // --- 响应式变量 ---
@@ -91,10 +90,10 @@ const submitting = ref(false);
// 核心表单对象:customer_id 已包含在内 // 核心表单对象:customer_id 已包含在内
const form = reactive({ const form = reactive({
website: "", website: "",
operator: "", company_id: null,
customer_id: null, operator_id: null,
staff_id: null, user_id: null,
is_complete: "未分配", status: 0,
remark: "", remark: "",
}); });
@@ -102,24 +101,14 @@ const form = reactive({
const rules = computed(() => { const rules = computed(() => {
const baseRules = { const baseRules = {
website: [{ required: true, message: "请输入网站地址", trigger: "blur" }], website: [{ required: true, message: "请输入网站地址", trigger: "blur" }],
customer_id: [{ required: true, message: "请选择客户单位", trigger: "change" }], company_id: [{ required: true, message: "请选择客户单位", trigger: "change" }],
operator: [{ required: true, message: "请选择运营人员", trigger: "change" }],
}; };
if (props.isEditing) {
baseRules.staff_id = [{ required: true, message: "请选择负责人", trigger: "change" }];
}
return baseRules; return baseRules;
}); });
// --- 逻辑:根据选择的单位过滤人员 ---
const filteredEmployees = computed(() => {
if (!form.customer_id) return [];
return props.customerEmployees.filter((emp) => emp.customer_id === form.customer_id);
});
// 单位切换时,重置运营人员选择 // 单位切换时,重置运营人员选择
const handleCustomerChange = () => { const handleCustomerChange = () => {
form.operator = ""; form.operator_id = null;
}; };
// --- 监听弹窗与回显 --- // --- 监听弹窗与回显 ---
@@ -134,13 +123,13 @@ watch(
// 深度合并数据 // 深度合并数据
Object.assign(form, props.formData); Object.assign(form, props.formData);
// 回显补充:如果传入的 formData 没有带 customer_id,通过 operator 反查补全 // 回显补充:如果传入的 formData 没有带 company_id,通过 operator_id 反查补全
if (!form.customer_id && props.formData.operator) { if (!form.company_id && props.formData.operator_id) {
const employee = props.customerEmployees.find( const employee = props.companiesWithOperators.flatMap(c => c.companyoperators).find(
(e) => e.employee_name === props.formData.operator (e) => e.id === props.formData.operator_id
); );
if (employee) { if (employee) {
form.customer_id = employee.customer_id; form.company_id = employee.customer_id;
} }
} }
} }
@@ -159,10 +148,10 @@ const resetForm = () => {
if (formRef.value) formRef.value.resetFields(); if (formRef.value) formRef.value.resetFields();
Object.assign(form, { Object.assign(form, {
website: "", website: "",
operator: "", company_id: null,
customer_id: null, operator_id: null,
staff_id: null, user_id: null,
is_complete: "未分配", status: 0,
remark: "", remark: "",
}); });
}; };
@@ -178,10 +167,17 @@ const handleSubmit = async () => {
// 复制提交数据,如果是新增,确保 staff_id 为空 // 复制提交数据,如果是新增,确保 staff_id 为空
const submitData = { ...form }; const submitData = { ...form };
// 核心修复:如果状态是”已完结”,确保 lock_time 不会被意外修改。
// 显式地使用表单打开前的原始 lock_time 值。
if (submitData.status === “已完结”) {
// 使用 props.formData.lock_time 来保证是原始值
submitData.lock_time = props.formData.lock_time || null;
}
delete submitData.create_time; delete submitData.create_time;
delete submitData.update_time; delete submitData.update_time;
if (!props.isEditing) { if (!props.isEditing) {
submitData.staff_id = null; submitData.user_id = null;
} }
emit("submit", submitData); emit("submit", submitData);

View File

@@ -1,10 +1,23 @@
<template> <template>
<el-card class="compact-card" shadow="never"> <el-card class="compact-card" shadow="never">
<el-table :data="tickets" v-loading="loading" header-cell-class-name="table-header" style="width: 100%"> <el-table
ref="tableRef"
:data="tickets"
v-loading="loading"
header-cell-class-name="table-header"
style="width: 100%"
@selection-change="$emit('selection-change', $event)"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="website" label="网站地址" min-width="180"> <el-table-column prop="website" label="网站地址" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-link type="primary" :href="row.website" target="_blank" class="site-link"> <el-link
type="primary"
:href="formatUrl(row.website)"
target="_blank"
rel="noopener noreferrer"
class="site-link"
>
{{ row.website }} {{ row.website }}
</el-link> </el-link>
</template> </template>
@@ -32,17 +45,28 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column
prop="company_name"
label="所属客户"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ row.company_name || "未关联" }}</span>
</template>
</el-table-column>
<el-table-column label="协同人员" width="180"> <el-table-column label="协同人员" width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="staff-wrapper"> <div class="staff-wrapper">
<div class="staff-item client"> <div class="staff-item client">
<span class="staff-role">运营</span> <span class="staff-role">运营</span>
<span class="staff-name">{{ row.operator?.trim() || '未指派' }}</span> <span class="staff-name">{{ row.operator_name?.trim() || "未指派" }}</span>
</div> </div>
<div class="staff-item internal"> <div class="staff-item internal">
<span class="staff-role">负责</span> <span class="staff-role">负责</span>
<span class="staff-name"> <span class="staff-name">
{{ row.principalStaff?.name || '未指派' }} {{ row.username?.trim() || "未指派" }}
</span> </span>
</div> </div>
</div> </div>
@@ -51,17 +75,22 @@
<el-table-column label="备注" min-width="150"> <el-table-column label="备注" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip v-if="row.remark" effect="dark" :content="row.remark" placement="top"> <el-tooltip
v-if="row.remark"
effect="dark"
:content="row.remark"
placement="top"
>
<span class="remark-text">{{ row.remark }}</span> <span class="remark-text">{{ row.remark }}</span>
</el-tooltip> </el-tooltip>
<span v-else class="empty-text">-</span> <span v-else class="empty-text">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="is_complete" label="状态" width="90" align="center"> <el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span class="status-badge" :style="getStatusStyle(row.is_complete)"> <span class="status-badge" :style="getStatusStyle(row.status)">
{{ row.is_complete }} {{ statusLabel(row.status) }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
@@ -73,21 +102,17 @@
type="success" type="success"
size="small" size="small"
effect="light" effect="light"
>已锁定</el-tag> >已锁定</el-tag
<el-tag >
v-else <el-tag v-else type="info" size="small" effect="plain">未锁定</el-tag>
type="info"
size="small"
effect="plain"
>未锁定</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="时间节点" width="160"> <el-table-column label="时间节点" width="160">
<template #default="{ row }"> <template #default="{ row }">
<div class="time-wrapper"> <div class="time-wrapper">
<span>创建:{{ formatTime(row.create_time) }}</span> <span>创建:{{ formatDateTime(row.create_time) }}</span>
<span class="update-label">更新:{{ formatTime(row.update_time) }}</span> <span class="update-label">更新:{{ formatDateTime(row.update_time) }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -113,40 +138,44 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue";
import { formatUrl, formatDateTime } from "@/utils/format";
defineProps({ defineProps({
tickets: { type: Array, default: () => [] }, tickets: { type: Array, default: () => [] },
loading: { type: Boolean, default: false } loading: { type: Boolean, default: false },
}) });
defineEmits(['edit', 'delete']) defineEmits(["edit", "delete", "selection-change"]);
const formatTime = (time) => { const tableRef = ref(null);
if (!time) return '-';
if (typeof time === 'string' && time.includes('-')) return time;
const date = new Date(time); defineExpose({
const m = (date.getMonth() + 1).toString().padStart(2, '0'); clearSelection() {
const d = date.getDate().toString().padStart(2, '0'); tableRef.value?.clearSelection();
const h = date.getHours().toString().padStart(2, '0'); },
const i = date.getMinutes().toString().padStart(2, '0'); });
return `${m}-${d} ${h}:${i}`;
} const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
const statusLabel = (status) => statusMap[status] || "未知";
const getStatusStyle = (status) => { const getStatusStyle = (status) => {
const styles = { const styles = {
"未分配": { color: '#909399', background: '#f4f4f5' }, 0: { color: "#909399", background: "#f4f4f5" },
"进行中": { color: '#409EFF', background: '#ecf5ff' }, 1: { color: "#409EFF", background: "#ecf5ff" },
"已完成": { color: '#67C23A', background: '#f0f9eb' }, 2: { color: "#67C23A", background: "#f0f9eb" },
"已完结": { color: '#e6a23c', background: '#fdf6ec' } 3: { color: "#e6a23c", background: "#fdf6ec" },
} };
return styles[status] || styles["未分配"]; return styles[status] || styles[0];
} };
</script> </script>
<style scoped> <style scoped>
.progress-container { .progress-container {
padding-right: 10px; padding-right: 10px;
} }
/* 调整进度条内部文字大小 */ /* 调整进度条内部文字大小 */
:deep(.el-progress__text) { :deep(.el-progress__text) {
font-size: 11px !important; font-size: 11px !important;
@@ -158,6 +187,7 @@ const getStatusStyle = (status) => {
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.staff-item { .staff-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -166,6 +196,7 @@ const getStatusStyle = (status) => {
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
.staff-role { .staff-role {
width: 34px; width: 34px;
height: 100%; height: 100%;
@@ -176,19 +207,34 @@ const getStatusStyle = (status) => {
background: #909399; background: #909399;
color: #fff; color: #fff;
} }
.staff-name { .staff-name {
padding-left: 6px; padding-left: 6px;
color: inherit; color: inherit;
} }
.staff-item.client { background: #f4f4f5; color: #909399; border: 1px solid #e9e9eb; }
.staff-item.internal { background: #ecf5ff; color: #409eff; border: 1px solid #d9ecff; } .staff-item.client {
.staff-item.internal .staff-role { background: #409eff; } background: #f4f4f5;
color: #909399;
border: 1px solid #e9e9eb;
}
.staff-item.internal {
background: #ecf5ff;
color: #409eff;
border: 1px solid #d9ecff;
}
.staff-item.internal .staff-role {
background: #409eff;
}
.data-num-text { .data-num-text {
font-family: 'Monaco', monospace; font-family: "Monaco", monospace;
font-weight: bold; font-weight: bold;
color: #409eff; color: #409eff;
} }
.remark-text { .remark-text {
font-size: 12px; font-size: 12px;
color: #606266; color: #606266;
@@ -197,9 +243,17 @@ const getStatusStyle = (status) => {
text-overflow: ellipsis; text-overflow: ellipsis;
display: block; display: block;
} }
.empty-text { color: #c0c4cc; font-size: 12px; }
.compact-card { border-radius: 8px; border: 1px solid #ebeef5; } .empty-text {
color: #c0c4cc;
font-size: 12px;
}
.compact-card {
border-radius: 8px;
border: 1px solid #ebeef5;
}
.status-badge { .status-badge {
display: inline-block; display: inline-block;
padding: 0 8px; padding: 0 8px;
@@ -208,12 +262,20 @@ const getStatusStyle = (status) => {
font-size: 11px; font-size: 11px;
border-radius: 11px; border-radius: 11px;
} }
.time-wrapper { .time-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 11px; font-size: 11px;
color: #606266; color: #606266;
} }
.update-label { color: #909399; }
:deep(.table-header) { background-color: #fafafa !important; font-weight: 600; } .update-label {
color: #909399;
}
:deep(.table-header) {
background-color: #fafafa !important;
font-weight: 600;
}
</style> </style>

View File

@@ -3,6 +3,13 @@
<el-container class="full-screen-container"> <el-container class="full-screen-container">
<el-aside width="200px" class="siderbar"> <el-aside width="200px" class="siderbar">
<div class="sidebar-header"> <div class="sidebar-header">
<!-- 修复:将 logo 区域包装成一个正确的外部链接 -->
<a
href="https://www.example.com"
target="_blank"
rel="noopener noreferrer"
class="logo-link"
>
<div class="logo-container"> <div class="logo-container">
<img src="@/assets/logo.jpg" alt="方舟网络" class="logo-image" /> <img src="@/assets/logo.jpg" alt="方舟网络" class="logo-image" />
<div class="text-content"> <div class="text-content">
@@ -10,18 +17,22 @@
<p class="system-name">工单管理系统</p> <p class="system-name">工单管理系统</p>
</div> </div>
</div> </div>
</a>
</div> </div>
<el-menu default-active="/" class="sidebar-menu" router> <el-menu default-active="/" class="sidebar-menu" router>
<el-menu-item index="/"> <el-menu-item index="/">
<el-icon><icon-menu /></el-icon> <el-icon><icon-menu /></el-icon>
<span>工单管理</span> <span>工单管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/stats"> <el-menu-item index="/stats">
<el-icon> <el-icon>
<document /> <document />
</el-icon> </el-icon>
<span>数据统计</span> <span>数据统计</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/customers"> <el-menu-item index="/customers">
<el-icon> <el-icon>
<User /> <User />
@@ -66,6 +77,8 @@
import { reactive, onMounted } from "vue"; import { reactive, onMounted } from "vue";
import { Menu as IconMenu, Document, User } from "@element-plus/icons-vue"; import { Menu as IconMenu, Document, User } from "@element-plus/icons-vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { resetRouter } from "@/router";
import { userApi } from "@/api/ticket";
const router = useRouter(); const router = useRouter();
const user = reactive({ const user = reactive({
@@ -82,10 +95,19 @@ onMounted(() => {
} }
}); });
const handleLogout = () => { const handleLogout = async () => {
localStorage.clear(); // 1. 调用后端接口销毁 session
sessionStorage.clear(); try {
router.push("/login"); await userApi.logout();
} catch (e) {
// 即使后端接口异常,也要完成前端的清理
}
localStorage.removeItem("user_info");
sessionStorage.removeItem("user_info");
resetRouter();
router.push({ name: "login" });
}; };
</script> </script>
@@ -119,6 +141,13 @@ const handleLogout = () => {
align-items: center; align-items: center;
} }
/* 修复:为新添加的链接添加样式 */
.logo-link {
text-decoration: none;
color: inherit;
display: block;
}
.logo-container { .logo-container {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,105 +1,220 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus' // 引入提示组件 import { ElMessage } from 'element-plus' // 引入提示组件
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // 公开路由,所有用户都可以访问
routes: [ export const publicRoutes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { isPublic: true }
}
]
// 动态路由,根据角色进行添加
export const dynamicRoutes = [
{ {
path: '/', path: '/',
component: () => import('../layouts/MainLayout.vue'), name: 'admin_layout',
component: () => import('@/layouts/MainLayout.vue'),
meta: { role: 'admin' },
redirect: { name: 'home' }, // 添加默认重定向
children: [ children: [
{ {
path: '', path: '',
name: 'home', name: 'home',
component: () => import('../views/TicketManagement.vue'), component: () => import('@/views/TicketManagement.vue'),
// 标记需要 admin 权限 meta: { title: '工单管理' }
meta: { requiresAuth: true, role: 'admin' }
}, },
{ {
path: 'stats', path: 'stats',
name: 'stats', name: 'stats',
component: () => import('../views/StatsView.vue'), component: () => import('@/views/StatsView.vue'),
meta: { requiresAuth: true, role: 'admin' } meta: { title: '数据统计' }
}, },
{ {
path: 'customers', path: 'customers',
name: 'customers', name: 'customers',
component: () => import('../views/CustomersManagement.vue'), component: () => import('@/views/CustomersManagement.vue'),
meta: { requiresAuth: true, role: 'admin' } meta: { title: '客户管理' }
}, }
] ]
}, },
{ {
path: '/user', path: '/user',
name: 'user_workdesk', name: 'user_workdesk',
component: () => import('../views/UserView.vue'), component: () => import('@/views/UserView.vue'),
// 标记只需要普通用户权限 meta: { role: 'user', title: '工作台' }
meta: { requiresAuth: true, role: 'user' }
}, },
{ {
path: '/client', path: '/client',
name: 'client_monitor', name: 'client_monitor',
component: () => import('../views/ClientView.vue'), component: () => import('@/views/ClientView.vue'),
// 标记只需要普通用户权限 meta: { role: 'client', title: '客户监控' }
meta: { requiresAuth: true, role: 'client' } }
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue')
},
] ]
// 创建路由实例
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: publicRoutes
}) })
// 全局前置守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
// 获取用户信息以判断角色 // ======工具函数======
const userInfoStr = localStorage.getItem('user_info') || sessionStorage.getItem('user_info');
const userInfo = userInfoStr ? JSON.parse(userInfoStr) : null;
// 简单的角色判定逻辑:如果用户名为 admin 则视为管理员,否则为普通用户 /* 获取用户信息 */
// 你也可以根据后端返回的具体的 role 字段来判断 function getUserInfo() {
const userRole = userInfo?.username === 'admin' ? 'admin' : 'user'; try {
const userInfoStr = localStorage.getItem('user_info') || sessionStorage.getItem('user_info')
const isRequiresAuth = to.matched.some(record => record.meta.requiresAuth); return userInfoStr ? JSON.parse(userInfoStr) : null
} catch (error) {
// 1. 未登录拦截 console.error('解析用户信息失败:', error)
if (isRequiresAuth && !token) { return null
next({ name: 'login', query: { redirect: to.fullPath } });
return;
}
// 2. 已登录状态下的逻辑
if (token) {
// 如果想去登录页,根据角色重定向到对应首页
if (to.name === 'login') {
userRole === 'admin' ? next({ name: 'home' }) : next({ name: 'user_workdesk' });
return;
}
// --- 核心修复:权限校验 ---
// 检查目标路由是否定义了需要的角色
const requiredRole = to.meta.role;
if (requiredRole && requiredRole !== userRole) {
// 如果是员工想进管理员页面
if (userRole === 'user' && requiredRole === 'admin') {
ElMessage.error('权限不足,无法访问管理后台');
next({ name: 'user_workdesk' }); // 踢回员工工作台
return;
}
// 如果是管理员想进员工页面 (通常允许,如果不允许也可以踢回 /)
if (userRole === 'admin' && requiredRole === 'user') {
next({ name: 'home' });
return;
} }
} }
/* 角色首页映射 */
const roleHomeMap = {
admin: { name: 'home' },
user: { name: 'user_workdesk' },
client: { name: 'client_monitor' }
};
/* 获取角色的默认路由 */
function getRoleHomeRoute(role) {
return roleHomeMap[role?.toLowerCase()] || { path: '/login' }
} }
// 3. 正常放行
next(); // ======动态路由管理======
let hasAddedRoutes = false; // 标记是否已添加动态路由
/* 添加动态路由 */
function addDynamicRoutes(role) {
try {
// 过滤当前角色可以访问的路由
const accessibleRoutes = dynamicRoutes.filter(route => {
return !route.meta?.role || route.meta.role === role;
}); });
export default router // 添加路由
accessibleRoutes.forEach(route => {
router.addRoute(route);
});
// 添加404路由
router.addRoute({
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/404View.vue'),
meta: { title: 404 }
});
hasAddedRoutes = true;
return true;
} catch (error) {
console.error('动态添加路由失败:', error);
return false;
}
}
/* 重置路由 */
export function resetRouter() {
// 移除动态添加的路由
dynamicRoutes.forEach(route => {
if (route.name && router.hasRoute(route.name)) {
router.removeRoute(route.name);
}
});
// 移除404路由
if (router.hasRoute('NotFound')) {
router.removeRoute('NotFound');
}
hasAddedRoutes = false;
// 清除用户信息
localStorage.removeItem('user_info');
sessionStorage.removeItem('user_info');
}
// ======路由守卫======
router.beforeEach(async (to, from, next) => {
const userInfo = getUserInfo();
const isLoggedIn = !!(userInfo && userInfo.role); // 判断是否登录(双重逻辑非)
// 1.访问登录页
if (to.name === 'login') {
if (isLoggedIn) {
// 已登录用户,重定向到角色首页
const homeRoute = getRoleHomeRoute(userInfo.role);
return next({ name: homeRoute.name });
}
// 未登录
return next();
}
// 2.未登录访问受保护的页面
if (!isLoggedIn) {
hasAddedRoutes = false; // 确保未添加动态路由
// 不是首页才进行提示
if (to.path !== '/') {
ElMessage.info('请先登录');
}
// 保存当前路径,登录后跳回
return next({
name: 'login',
query: { redirect: to.fullPath }
})
}
// 3.已登录,动态添加路由
if (!hasAddedRoutes) {
const success = addDynamicRoutes(userInfo.role);
if (!success) {
// 添加失败,清空状态,重新登录
resetRouter();
ElMessage.error('加载用户权限失败,请重新登录');
return next({ name: 'login' });
}
// 用新注册的路由重新匹配原始导航路径(排除404通配)
const resolved = router.resolve(to);
if (resolved.matched.length > 0 && resolved.name !== 'NotFound') {
return next({ ...to, replace: true });
}
// 路径无匹配(如非Admin用户访问 /),回退到角色首页
const homeRoute = getRoleHomeRoute(userInfo.role);
return next({ name: homeRoute.name });
}
// 4. 已登录且路由已添加,放行
next()
})
// ======全局后置守卫======
router.afterEach((to) => {
// 设置页面标题
if (to.meta?.title) {
document.title = `方舟工单系统 - ${to.meta.title}`;
}
});
export default router;

49
src/utils/format.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* 补全 URL 协议头,防止无效跳转
*/
export function formatUrl(url) {
if (!url || typeof url !== 'string') return '#'
if (url.startsWith('http://') || url.startsWith('https://')) return url
return `http://${url}`
}
/**
* 将日期字符串或时间戳转换为 YYYY-MM-DD 格式
*/
export function formatDate(time) {
if (!time || time === '0') return '-'
if (typeof time === 'string' && time.includes('-')) {
return time.slice(0, 10)
}
const timestamp = Number(time)
if (!isNaN(timestamp) && timestamp > 0) {
const date = new Date(timestamp * 1000)
if (!isNaN(date.getTime())) {
const y = date.getFullYear()
const m = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
return `${y}-${m}-${d}`
}
}
return '-'
}
/**
* 将时间转换为 MM-DD HH:mm 格式
*/
export function formatDateTime(time) {
if (!time) return '-'
if (typeof time === 'string' && time.includes('-')) return time
const date = new Date(time)
if (isNaN(date.getTime())) return '-'
const m = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const i = date.getMinutes().toString().padStart(2, '0')
return `${m}-${d} ${h}:${i}`
}

View File

@@ -1,72 +1,52 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router, { resetRouter } from '@/router'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
// 标记位,防止重复弹窗
let isRelogging = false let isRelogging = false
// 请求拦截器 function createService(basePath) {
service.interceptors.request.use( const service = axios.create({
config => { baseURL: `/api/${basePath}`,
// 自动获取 Token(兼顾两种存储方式) timeout: 10000,
const token = localStorage.getItem('token') || sessionStorage.getItem('token') withCredentials: true,
if (token) { })
config.headers['Authorization'] = `Bearer ${token}`
service.interceptors.response.use(
response => response.data,
error => {
const resData = error.response?.data
const status = error.response?.status
// session过期处理
if (status === 401) {
if (!isRelogging) {
isRelogging = true
resetRouter()
ElMessage.error('登录已过期,请重新登录')
router.push({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
}).finally(() => {
isRelogging = false
})
}
return new Promise(() => { })
}
const msg = resData?.message || '服务器响应异常'
ElMessage.error(msg)
return Promise.reject(error)
} }
return config
},
error => Promise.reject(error)
) )
// 响应拦截器 return service
service.interceptors.response.use(
// 接口请求返回状态码是200,进入第一个
response => {
const token = response.headers['Authorization'];
if (token) {
localStorage.setItem('token', token);
}
return response.data;
},
error => {
const resData = error.response?.data;
const status = error.response?.status;
console.log("返回状态码", status);
// 1. 判定是否为 Token 相关的认证错误
// 包含 500 状态码且消息含有 token 字样(如 decode 失败、过期等)
if (status === 401) {
if (!isRelogging) {
isRelogging = true; // 锁定
localStorage.removeItem('token');
// 清除用户信息,防止 UserView.vue 的 computed 报错
localStorage.removeItem('user_info');
ElMessage.error('登录已失效,请重新登录');
router.push('/login').then(() => {
// 2. 延迟解锁,确保页面跳转期间的所有并发请求都已返回并被此拦截器捕获
setTimeout(() => {
isRelogging = false;
}, 3000);
});
} }
return new Promise(() => { }); export const adminRequest = createService('admin')
} export const indexRequest = createService('index')
export default adminRequest
const msg = resData?.message || '服务器响应异常';
ElMessage.error(msg);
return Promise.reject(error);
}
);
export default service

37
src/views/404View.vue Normal file
View File

@@ -0,0 +1,37 @@
<template>
<div class="not-found-container">
<h1>404 - 页面未找到</h1>
<p>您要查找的页面不存在或您没有权限访问。</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
const router = useRouter();
const goHome = () => {
const userInfoStr =
localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
const userRole = userInfoStr ? JSON.parse(userInfoStr).role : null;
const roleHomeMap = {
admin: { name: "home" },
user: { name: "user_workdesk" },
client: { name: "client_monitor" },
};
const homeRoute = roleHomeMap[userRole];
// 如果用户角色有对应的首页,则跳转;否则,跳转到登录页。
router.push(homeRoute || { name: "login" });
};
</script>
<style scoped>
.not-found-container {
text-align: center;
padding-top: 100px;
}
</style>

View File

@@ -114,7 +114,7 @@
<el-table-column label="更新日期" width="140" align="center"> <el-table-column label="更新日期" width="140" align="center">
<template #default="scope"> <template #default="scope">
<span class="date-text">{{ <span class="date-text">{{
formatDateOnly( formatDate(
scope.row.lock_time || scope.row.update_time || scope.row.create_time scope.row.lock_time || scope.row.update_time || scope.row.create_time
) )
}}</span> }}</span>
@@ -143,8 +143,9 @@ import {
CircleCheck, CircleCheck,
Monitor, Monitor,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { ticketApi } from "@/api/ticket"; import { ticketApi, userApi } from "@/api/ticket";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { formatDate } from "@/utils/format";
const router = useRouter(); const router = useRouter();
const loading = ref(false); const loading = ref(false);
@@ -259,26 +260,18 @@ const getStatusClass = (status) => {
return map[status] || ""; return map[status] || "";
}; };
const formatDateOnly = (ts) => {
if (!ts || ts === "0" || ts === "") return "-";
const date = !isNaN(ts)
? new Date(String(ts).length === 10 ? ts * 1000 : parseInt(ts))
: new Date(ts);
return isNaN(date.getTime())
? "-"
: `${date.getFullYear()}-${(date.getMonth() + 1)
.toString()
.padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
};
const handlePageChange = (p) => { const handlePageChange = (p) => {
page.value = p; page.value = p;
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}; };
const handleLogout = () => { const handleLogout = async () => {
localStorage.clear(); try {
sessionStorage.clear(); await userApi.logout();
} catch (e) {}
localStorage.removeItem("user_info");
sessionStorage.removeItem("user_info");
router.push("/login"); router.push("/login");
}; };

View File

@@ -17,7 +17,7 @@
</div> </div>
<div class="brand-text"> <div class="brand-text">
<h1 class="title">方舟工单</h1> <h1 class="title">方舟工单</h1>
<span class="sub-text">ARK TICKET SYSTEM v3.0</span> <span class="sub-text">ARK TICKET SYSTEM v4.0</span>
</div> </div>
</div> </div>
@@ -29,7 +29,7 @@
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" class="ark-form"> <el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" class="ark-form">
<el-form-item prop="username"> <el-form-item prop="username">
<div class="input-prefix">ID</div> <div class="input-prefix">ID</div>
<el-input v-model="loginForm.username" placeholder="工员编号" class="ark-input" /> <el-input v-model="loginForm.username" placeholder="员工编号" class="ark-input" />
</el-form-item> </el-form-item>
<el-form-item prop="password"> <el-form-item prop="password">
@@ -64,17 +64,17 @@ import { ref, reactive } from "vue";
import { DataBoard } from "@element-plus/icons-vue"; import { DataBoard } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { userApi } from "@/api/ticket"; import { userApi } from "@/api/ticket";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
const router = useRouter(); const router = useRouter();
const route = useRoute();
const loginForm = reactive({ username: "", password: "" }); const loginForm = reactive({ username: "", password: "" });
const rememberMe = ref(false); const rememberMe = ref(false);
const loading = ref(false); const loading = ref(false);
const loginFormRef = ref(null); const loginFormRef = ref(null);
const loginRules = reactive({ const loginRules = reactive({
username: [{ required: true, message: "请输入工员编号", trigger: "blur" }], username: [{ required: true, message: "请输入员工编号", trigger: "blur" }],
password: [{ required: true, message: "请输入密钥", trigger: "blur" }], password: [{ required: true, message: "请输入密钥", trigger: "blur" }],
}); });
@@ -85,52 +85,32 @@ const handleLogin = async () => {
loading.value = true; loading.value = true;
const res = await userApi.login(loginForm.username, loginForm.password); const res = await userApi.login(loginForm.username, loginForm.password);
// console.log(res)
// 注意:这里由于你之前的封装,可能需要判断 res.data.code 还是 res.code
// 如果用了拦截器,直接判断 res.code
if (res.code === 200) { if (res.code === 200) {
const token = res.data.token; // ThinkPHP 会自动通过 Set-Cookie 下发 session,浏览器自动接管,无需手动存 token
const userInfo = JSON.stringify(res.data.user_info); const userInfo = res.data;
const role = res.data.user_info.role; const userInfoStr = JSON.stringify(userInfo);
// console.log(role) // 用户信息存前端,仅用于显示用户名和角色路由判断
const storage = rememberMe.value ? localStorage : sessionStorage;
storage.setItem("user_info", userInfoStr);
// --- 核心逻辑开始 --- ElMessage({
if (rememberMe.value) { message: "身份验证通过,方舟系统加载中...",
// 勾选了:存入 localStorage,实现持久化 type: "success",
localStorage.setItem("token", token); duration: 300,
localStorage.setItem("user_info", userInfo); });
// 触发路由守卫,自动处理跳转
await router.push(route.query.redirect || "/");
} else { } else {
// 没勾选:存入 sessionStorage,关闭标签页即失效 ElMessage.error(res.message || "登录失败,请检查账号和密码");
sessionStorage.setItem("token", token);
sessionStorage.setItem("user_info", userInfo);
}
// --- 核心逻辑结束 ---
ElMessage.success("身份验证通过,方舟系统加载中...");
// 3. 根据角色执行路由跳转
setTimeout(() => {
switch (role) {
case "admin":
router.push("/"); // 管理员跳转至主页
break;
case "user":
router.push("/user"); // 跳转至 UserView (请确保路由配置中有此路径)
break;
case "client":
router.push("/client"); // 跳转至 ClientView (请确保路由配置中有此路径)
break;
default:
ElMessage.warning("未识别的角色权限,请联系管理员");
break;
}
}, 1000);
} else {
ElMessage.error(res.message || "登录失败");
} }
} catch (error) { } catch (error) {
console.error("Login Error:", error); if (error.name !== "ValidationError") {
ElMessage.error("登录请求异常,请稍后重试");
// console.error("登录错误:", error);
}
} finally { } finally {
loading.value = false; loading.value = false;
} }

View File

@@ -53,11 +53,18 @@
<div class="left"> <div class="left">
<el-button type="primary" @click="handleAdd" :icon="Plus">新增任务</el-button> <el-button type="primary" @click="handleAdd" :icon="Plus">新增任务</el-button>
</div> </div>
<TicketFilter :operators="operatorsForFilter" :staffs="staffList" :filters="filters" <TicketFilter :operators="operatorsForFilter" :staffs="userStaffList" :filters="filters"
@filter-change="handleFilterChange" @reset="handleFilterReset" /> @filter-change="handleFilterChange" @reset="handleFilterReset" />
</div> </div>
<TicketList :tickets="tickets" :loading="loading" @edit="handleEdit" @delete="handleDelete" /> <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"> <div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit" <el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
@@ -66,14 +73,13 @@
</div> </div>
</div> </div>
<TicketForm v-model="showAddDialog" :form-data="currentTicket" :customers="customerList" <TicketForm v-model="showAddDialog" :form-data="currentTicket" :companiesWithOperators="companywithoperatorsList"
:customer-employees="allCustomerEmployees" :staffs="staffList" :is-editing="isEditing" :user-staffs="userStaffList" :is-editing="isEditing" @submit="handleFormSubmit" />
@submit="handleFormSubmit" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from "vue"; import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { import {
Plus, Plus,
@@ -85,7 +91,7 @@ import {
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
// API 引入 // API 引入
import { ticketApi, customerApi } from "@/api/ticket"; import { ticketApi } from "@/api/ticket";
// 组件引入 // 组件引入
import TicketFilter from "@/components/ticket/TicketFilter.vue"; import TicketFilter from "@/components/ticket/TicketFilter.vue";
@@ -94,14 +100,16 @@ import TicketForm from "@/components/ticket/TicketForm.vue";
// --- 响应式状态 --- // --- 响应式状态 ---
const tickets = ref([]); const tickets = ref([]);
const operators = ref([]); const userStaffList = ref([]);
const staffList = ref([]); const companywithoperatorsList = ref([]);
const customerList = ref([]);
const allCustomerEmployees = ref([]);
const loading = ref(false); const loading = ref(false);
const showAddDialog = ref(false); const showAddDialog = ref(false);
const isEditing = ref(false); const isEditing = ref(false);
const currentTicket = ref({}); const currentTicket = ref({});
const selectedTickets = ref([]);
const ticketListRef = ref(null);
const operatorsForFilter = ref([]);
const allStats = reactive({ const allStats = reactive({
total: 0, total: 0,
@@ -119,54 +127,56 @@ const pagination = reactive({
const filters = reactive({ const filters = reactive({
status: null, status: null,
operator_name: null, operator_id: null,
staff_id: null, user_id: null,
search: null, search: null,
sort_order: "desc", sort_order: "desc",
}); });
// --- 核心数据加载方法 --- // --- 核心数据加载方法 ---
/* 获取运营人员数据的方法*/
// 在 TicketManagement.vue 中利用 computed 提取 const fetchOperatorsForFilter = async () => {
const operatorsForFilter = computed(() => {
// 返回包含 ID、姓名和公司名的对象数组
return allCustomerEmployees.value.map((emp) => {
// 在 allCustomers 中找到该员工所属的公司名
const company = customerList.value.find((c) => c.customer_id === emp.customer_id);
return {
id: emp.employee_id,
name: emp.employee_name,
company: company ? company.company_name : "未知单位",
};
});
});
/**
* 加载客户单位及对应员工数据
* 根据你提供的接口返回结构进行了适配
*/
const loadCustomerData = async () => {
try { try {
const res = await customerApi.getCustomers(); const response = await ticketApi.getOperatorsNew();
// 适配点:根据返回的 code: 200 逻辑进行判断 if (response.code === 200) {
if (res.code === 200) { // 将新的嵌套数据结构转换为原来的扁平格式
// 适配点:直接从 res 中获取 customers 和 employees 数组 operatorsForFilter.value = response.data.flatMap(company =>
customerList.value = res.customers || []; company.companyoperators.map(op => ({
allCustomerEmployees.value = res.employees || []; id: op.id,
operator_name: op.operator_name,
company_name: company.company_name
}))
);
} }
} catch (error) { } catch (error) {
console.error("获取客户数据失败:", 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 loadStaffData = async () => { const loadUserStaffData = async () => {
try { try {
const res = await ticketApi.getStaffs(); const res = await ticketApi.getUserStaffs();
if (res.code) { if (res.code === 200) {
staffList.value = res.data; userStaffList.value = res.data;
} }
} catch (error) { } catch (error) {
console.error("获取我方员工列表失败:", error); console.error("获取我方员工列表失败:", error);
@@ -178,13 +188,26 @@ const loadStaffData = async () => {
*/ */
const initGlobalStats = async () => { const initGlobalStats = async () => {
try { try {
const response = await ticketApi.getticketsstatuscount(); const response = await ticketApi.getticketdashboardstats();
if (response.code) { if (response.code === 200) {
allStats.total = response.data["total"]; allStats.total = response.data.total;
allStats.pending = response.data["pending"]; const statusCounts = response.data.status_counts || [];
allStats.ongoing = response.data["ongoing"]; statusCounts.forEach((item) => {
allStats.completed = response.data["completed"]; switch (item.status) {
allStats.is_completed = response.data["is_completed"]; 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) { } catch (error) {
console.error("统计全局数据失败:", error); console.error("统计全局数据失败:", error);
@@ -224,18 +247,20 @@ const refreshAll = () => {
const handleFilterChange = (newFilters) => { const handleFilterChange = (newFilters) => {
Object.assign(filters, newFilters); Object.assign(filters, newFilters);
pagination.page = 1; pagination.page = 1;
selectedTickets.value = [];
loadTickets(); loadTickets();
}; };
const handleFilterReset = () => { const handleFilterReset = () => {
Object.assign(filters, { Object.assign(filters, {
status: null, status: null,
operator_name: null, operator_id: null,
staff_id: null, user_id: null,
search: null, search: null,
sort_order: "desc", sort_order: "desc",
}); });
pagination.page = 1; pagination.page = 1;
selectedTickets.value = [];
loadTickets(); loadTickets();
}; };
@@ -289,7 +314,6 @@ const handleDelete = async (id) => {
cancelButtonText: "取消", cancelButtonText: "取消",
}); });
const res = await ticketApi.deleteTicket(id); const res = await ticketApi.deleteTicket(id);
// console.log('删除',res)
if (res.code) { if (res.code) {
ElMessage.success("删除成功"); ElMessage.success("删除成功");
refreshAll(); refreshAll();
@@ -299,15 +323,28 @@ const handleDelete = async (id) => {
} }
}; };
// 加载所有运营人员 const handleBatchFinish = async () => {
const loadOperators = async () => { const ids = selectedTickets.value.map((t) => t.id);
try { try {
const response = await ticketApi.getOperators(); await ElMessageBox.confirm(
if (response.code) { `确认将选中的 ${ids.length} 个工单一键已完结?`,
operators.value = response.data; "批量操作",
{ type: "warning", confirmButtonText: "确认已完结" }
);
const res = await ticketApi.batchUpdateTicket({
ids,
status: "已完结",
remark: "批量结单",
});
if (res.code === 200) {
ElMessage.success(res.data?.msg || `成功完结 ${ids.length} 个工单`);
ticketListRef.value.clearSelection();
refreshAll();
} else {
ElMessage.error(res.msg || "批量操作失败");
} }
} catch (error) { } catch (error) {
console.error("获取运营人员筛选列表失败:", error); if (error !== "cancel") ElMessage.error("批量操作失败");
} }
}; };
@@ -315,17 +352,18 @@ const handleSizeChange = (val) => {
pagination.limit = val; pagination.limit = val;
loadTickets(); loadTickets();
}; };
const handleCurrentChange = (val) => { const handleCurrentChange = (val) => {
pagination.page = val; pagination.page = val;
loadTickets(); loadTickets();
}; };
onMounted(() => { onMounted(() => {
fetchOperatorsForFilter();
loadTickets(); loadTickets();
initGlobalStats(); initGlobalStats();
loadOperators(); loadUserStaffData();
loadStaffData(); loadCompanyWithOperatorsData();
loadCustomerData(); // 级联数据加载
}); });
</script> </script>
@@ -445,6 +483,22 @@ onMounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
} }
.batch-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 12px;
background: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 8px;
}
.batch-info {
font-size: 13px;
color: #606266;
}
.pagination-container { .pagination-container {
margin-top: 20px; margin-top: 20px;
display: flex; display: flex;

View File

@@ -85,12 +85,18 @@
</template> </template>
<el-table-column prop="id" label="ID" width="70" align="center" /> <el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column <el-table-column prop="website" label="网站名称" min-width="150">
prop="website" <template #default="{ row }">
label="网站名称" <el-link
min-width="150" type="primary"
show-overflow-tooltip :href="formatUrl(row.website)"
/> target="_blank"
rel="noopener noreferrer"
>
{{ row.website }}
</el-link>
</template>
</el-table-column>
<el-table-column label="数据量" width="150" align="center"> <el-table-column label="数据量" width="150" align="center">
<template #default="scope"> <template #default="scope">
@@ -255,10 +261,11 @@
<template #default="scope"> <template #default="scope">
<span class="pure-date"> <span class="pure-date">
{{ {{
(["已完成", "已完结"].includes(currentStatus) formatDate(
["已完成", "已完结"].includes(currentStatus)
? scope.row.lock_time ? scope.row.lock_time
: scope.row.create_time : scope.row.create_time
)?.slice(0, 10) || "-" )
}} }}
</span> </span>
</template> </template>
@@ -319,7 +326,9 @@ import {
Checked, Checked,
Loading, Loading,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { ticketApi } from "@/api/ticket"; import { ticketApi, userApi } from "@/api/ticket";
import { resetRouter } from "@/router";
import { formatUrl, formatDate } from "@/utils/format";
const router = useRouter(); const router = useRouter();
const loading = ref(false); const loading = ref(false);
@@ -377,7 +386,6 @@ const fetchTickets = async () => {
loading.value = false; loading.value = false;
return; return;
} }
console.log("员工id", realStaffInfo.value.id);
params.staff_id = realStaffInfo.value.id; params.staff_id = realStaffInfo.value.id;
} }
@@ -511,10 +519,16 @@ const handleCancel = (row) => {
}; };
const handleLogout = () => { const handleLogout = () => {
ElMessageBox.confirm("确定退出当前账号?", "提示", { type: "warning" }).then(() => { ElMessageBox.confirm("确定退出当前账号?", "提示", { type: "warning" }).then(async () => {
localStorage.clear(); try {
sessionStorage.clear(); await userApi.logout();
router.push("/login"); } catch (e) {}
localStorage.removeItem("user_info");
sessionStorage.removeItem("user_info");
resetRouter();
router.push({ name: "login" });
}); });
}; };

View File

@@ -19,9 +19,10 @@ export default defineConfig({
server: { server: {
port: 8080, // 前端启动的端口 port: 8080, // 前端启动的端口
proxy: { proxy: {
'/api': { // 所有以api开头的请求都会被代理 '/api': {
target: 'http://localhost', target: 'http://localhost',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
} }
} }
} }