减少冗余代码
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
|
||||
@@ -1,78 +1,80 @@
|
||||
import request from '@/utils/request'
|
||||
import { adminRequest, indexRequest } from '@/utils/request'
|
||||
|
||||
// ==================== 前台接口(/api/index) ====================
|
||||
|
||||
export const userApi = {
|
||||
// 登录接口
|
||||
login(username, password) {
|
||||
// 这里的 data 会被 axios 放在请求体 (Body) 中发送
|
||||
return request.post('/login', {
|
||||
username,
|
||||
password
|
||||
})
|
||||
return indexRequest.post('/login', { username, password })
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
return request.post('/logout')
|
||||
return indexRequest.post('/logout')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 后台接口(/api/admin) ====================
|
||||
|
||||
export const ticketApi = {
|
||||
//获取工单各个完成状态的统计数量
|
||||
getticketsstatuscount(){
|
||||
return request.get('/getticketstatuscount')
|
||||
// 获取工单各完成状态的统计数量
|
||||
getticketdashboardstats() {
|
||||
return adminRequest.get('/getticketdashboardstats')
|
||||
},
|
||||
|
||||
// 获取工单列表
|
||||
getTickets(params) {
|
||||
return request.get('/ticketsfind', { params })
|
||||
return adminRequest.get('/ticketsfind', { params })
|
||||
},
|
||||
|
||||
// 新增工单
|
||||
addTicket(data) {
|
||||
return request.post('/addticket', data)
|
||||
return adminRequest.post('/addticket', data)
|
||||
},
|
||||
|
||||
// 更新工单
|
||||
updateTicket(id, data) {
|
||||
return request.put(`/ticket/${id}`, data)
|
||||
return adminRequest.put(`/ticket/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除工单
|
||||
deleteTicket(id) {
|
||||
return request.delete(`/ticket/${id}`)
|
||||
return adminRequest.delete(`/ticket/${id}`)
|
||||
},
|
||||
|
||||
// 获取运营人员列表
|
||||
getOperators() {
|
||||
return request.get('/operators')
|
||||
// 获取运营人员列表(按公司分组)
|
||||
getOperatorsNew() {
|
||||
return adminRequest.get('/getOperatorList')
|
||||
},
|
||||
|
||||
// 获取员工列表
|
||||
getStaffs(params) {
|
||||
return request.get('/getStaffList', { params })
|
||||
}
|
||||
getUserStaffs() {
|
||||
return adminRequest.get('/getUserStaffList')
|
||||
},
|
||||
|
||||
// 批量更新工单
|
||||
batchUpdateTicket(data) {
|
||||
return adminRequest.put('/tickets/batch-update', data)
|
||||
},
|
||||
}
|
||||
|
||||
export const customerApi = {
|
||||
getCustomers() {
|
||||
return request.get('/customers')
|
||||
return adminRequest.get('/customers')
|
||||
},
|
||||
|
||||
|
||||
// 添加客户,data 结构应为 { company_name: 'xxx' }
|
||||
addCustomerName(data) {
|
||||
return request.post('/addcustomername', data)
|
||||
return adminRequest.post('/addcustomername', 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}`)
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,26 @@
|
||||
|
||||
<div class="input-group-row">
|
||||
<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">
|
||||
<el-option v-for="op in operators" :key="op.id" :label="`${op.name} (${op.company})`"
|
||||
:value="op.name" />
|
||||
<el-option-group v-for="group in groupedOperators" :key="group.company_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-form-item>
|
||||
|
||||
<div class="divider-wrapper">/</div>
|
||||
|
||||
<el-form-item label="我方负责人">
|
||||
<el-select v-model="localFilters.staff_id" placeholder="全部负责人" clearable filterable class="wide-select"
|
||||
<el-form-item label="我方牛马">
|
||||
<el-select v-model="localFilters.user_id" placeholder="全部牛马" clearable filterable class="wide-select"
|
||||
@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-form-item>
|
||||
|
||||
<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">
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
@@ -66,7 +68,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from "vue";
|
||||
import { reactive, watch, computed } from "vue";
|
||||
import { Search, Sort } from "@element-plus/icons-vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -75,9 +77,51 @@ const props = defineProps({
|
||||
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(
|
||||
() => props.filters,
|
||||
@@ -197,4 +241,12 @@ const setSort = (order) => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 运营人员分组标题样式 */
|
||||
:deep(.el-select-group__title) {
|
||||
background-color: #f5f7fa;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
padding-left: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
destroy-on-close @close="handleClose">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="compact-form">
|
||||
<div class="form-section">
|
||||
<div class="section-title">业务基础信息</div>
|
||||
<div class="section-title">基础信息</div>
|
||||
<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>
|
||||
<el-icon>
|
||||
<Link />
|
||||
@@ -16,34 +16,34 @@
|
||||
</div>
|
||||
|
||||
<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-select v-model="form.customer_id" placeholder="请选择单位" style="width: 100%" filterable
|
||||
<el-form-item label="客户单位" prop="company_id" class="client-item">
|
||||
<el-select v-model="form.company_id" placeholder="请选择单位" style="width: 100%" filterable
|
||||
@change="handleCustomerChange">
|
||||
<el-option v-for="item in customers" :key="item.customer_id" :label="item.company_name"
|
||||
:value="item.customer_id" />
|
||||
<el-option v-for="item in companiesWithOperators.filter(c => c.id !== 0)" :key="item.id" :label="item.company_name"
|
||||
:value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="运营人员 (外方)" prop="operator" class="client-item">
|
||||
<el-select v-model="form.operator" placeholder="请选择人员" style="width: 100%" filterable
|
||||
:disabled="!form.customer_id">
|
||||
<el-option v-for="item in filteredEmployees" :key="item.employee_id" :label="item.employee_name"
|
||||
:value="item.employee_name" />
|
||||
<el-form-item label="运营人员" prop="operator_id" class="client-item">
|
||||
<el-select v-model="form.operator_id" placeholder="请选择人员" style="width: 100%" filterable
|
||||
:disabled="!form.company_id">
|
||||
<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.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isEditing" label="负责人 (我方)" prop="staff_id" class="owner-item column-full">
|
||||
<el-select v-model="form.staff_id" placeholder="请选择我方负责人" style="width: 100%" filterable>
|
||||
<el-option v-for="item in staffs" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-form-item label="我方牛马" prop="user_id" class="owner-item column-full">
|
||||
<el-select v-model="form.user_id" placeholder="请选择牛马" style="width: 100%" filterable>
|
||||
<el-option v-for="item in userStaffs" :key="item.id" :label="item.username" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<el-form-item label="当前进度">
|
||||
<el-radio-group v-model="form.is_complete" class="modern-radio-group">
|
||||
<el-form-item label="进度">
|
||||
<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>
|
||||
@@ -51,8 +51,8 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注说明" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写相关的补充信息或注意事项..." resize="none" />
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -77,10 +77,9 @@ const emit = defineEmits(["update:modelValue", "submit"]);
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
formData: Object,
|
||||
customers: Array,
|
||||
customerEmployees: Array,
|
||||
staffs: Array,
|
||||
userStaffs: Array,
|
||||
isEditing: Boolean,
|
||||
companiesWithOperators: Array,
|
||||
});
|
||||
|
||||
// --- 响应式变量 ---
|
||||
@@ -91,10 +90,10 @@ const submitting = ref(false);
|
||||
// 核心表单对象:customer_id 已包含在内
|
||||
const form = reactive({
|
||||
website: "",
|
||||
operator: "",
|
||||
customer_id: null,
|
||||
staff_id: null,
|
||||
is_complete: "未分配",
|
||||
company_id: null,
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
status: 0,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
@@ -102,24 +101,14 @@ const form = reactive({
|
||||
const rules = computed(() => {
|
||||
const baseRules = {
|
||||
website: [{ required: true, message: "请输入网站地址", trigger: "blur" }],
|
||||
customer_id: [{ required: true, message: "请选择客户单位", trigger: "change" }],
|
||||
operator: [{ required: true, message: "请选择运营人员", trigger: "change" }],
|
||||
company_id: [{ required: true, message: "请选择客户单位", trigger: "change" }],
|
||||
};
|
||||
if (props.isEditing) {
|
||||
baseRules.staff_id = [{ required: true, message: "请选择负责人", trigger: "change" }];
|
||||
}
|
||||
return baseRules;
|
||||
});
|
||||
|
||||
// --- 逻辑:根据选择的单位过滤人员 ---
|
||||
const filteredEmployees = computed(() => {
|
||||
if (!form.customer_id) return [];
|
||||
return props.customerEmployees.filter((emp) => emp.customer_id === form.customer_id);
|
||||
});
|
||||
|
||||
// 单位切换时,重置运营人员选择
|
||||
const handleCustomerChange = () => {
|
||||
form.operator = "";
|
||||
form.operator_id = null;
|
||||
};
|
||||
|
||||
// --- 监听弹窗与回显 ---
|
||||
@@ -134,13 +123,13 @@ watch(
|
||||
// 深度合并数据
|
||||
Object.assign(form, props.formData);
|
||||
|
||||
// 回显补充:如果传入的 formData 没有带 customer_id,通过 operator 反查补全
|
||||
if (!form.customer_id && props.formData.operator) {
|
||||
const employee = props.customerEmployees.find(
|
||||
(e) => e.employee_name === props.formData.operator
|
||||
// 回显补充:如果传入的 formData 没有带 company_id,通过 operator_id 反查补全
|
||||
if (!form.company_id && props.formData.operator_id) {
|
||||
const employee = props.companiesWithOperators.flatMap(c => c.companyoperators).find(
|
||||
(e) => e.id === props.formData.operator_id
|
||||
);
|
||||
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();
|
||||
Object.assign(form, {
|
||||
website: "",
|
||||
operator: "",
|
||||
customer_id: null,
|
||||
staff_id: null,
|
||||
is_complete: "未分配",
|
||||
company_id: null,
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
status: 0,
|
||||
remark: "",
|
||||
});
|
||||
};
|
||||
@@ -178,10 +167,17 @@ const handleSubmit = async () => {
|
||||
// 复制提交数据,如果是新增,确保 staff_id 为空
|
||||
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.update_time;
|
||||
if (!props.isEditing) {
|
||||
submitData.staff_id = null;
|
||||
submitData.user_id = null;
|
||||
}
|
||||
|
||||
emit("submit", submitData);
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
<template>
|
||||
<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">
|
||||
<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 }}
|
||||
</el-link>
|
||||
</template>
|
||||
@@ -32,17 +45,28 @@
|
||||
</template>
|
||||
</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">
|
||||
<template #default="{ row }">
|
||||
<div class="staff-wrapper">
|
||||
<div class="staff-item client">
|
||||
<span class="staff-role">运营</span>
|
||||
<span class="staff-name">{{ row.operator?.trim() || '未指派' }}</span>
|
||||
<span class="staff-name">{{ row.operator_name?.trim() || "未指派" }}</span>
|
||||
</div>
|
||||
<div class="staff-item internal">
|
||||
<span class="staff-role">负责</span>
|
||||
<span class="staff-name">
|
||||
{{ row.principalStaff?.name || '未指派' }}
|
||||
{{ row.username?.trim() || "未指派" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,17 +75,22 @@
|
||||
|
||||
<el-table-column label="备注" min-width="150">
|
||||
<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>
|
||||
</el-tooltip>
|
||||
<span v-else class="empty-text">-</span>
|
||||
</template>
|
||||
</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 }">
|
||||
<span class="status-badge" :style="getStatusStyle(row.is_complete)">
|
||||
{{ row.is_complete }}
|
||||
<span class="status-badge" :style="getStatusStyle(row.status)">
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -73,21 +102,17 @@
|
||||
type="success"
|
||||
size="small"
|
||||
effect="light"
|
||||
>已锁定</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>未锁定</el-tag>
|
||||
>已锁定</el-tag
|
||||
>
|
||||
<el-tag v-else type="info" size="small" effect="plain">未锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="时间节点" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="time-wrapper">
|
||||
<span>创建:{{ formatTime(row.create_time) }}</span>
|
||||
<span class="update-label">更新:{{ formatTime(row.update_time) }}</span>
|
||||
<span>创建:{{ formatDateTime(row.create_time) }}</span>
|
||||
<span class="update-label">更新:{{ formatDateTime(row.update_time) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -113,40 +138,44 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { formatUrl, formatDateTime } from "@/utils/format";
|
||||
|
||||
defineProps({
|
||||
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) => {
|
||||
if (!time) return '-';
|
||||
if (typeof time === 'string' && time.includes('-')) return time;
|
||||
const tableRef = ref(null);
|
||||
|
||||
const date = new Date(time);
|
||||
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}`;
|
||||
}
|
||||
defineExpose({
|
||||
clearSelection() {
|
||||
tableRef.value?.clearSelection();
|
||||
},
|
||||
});
|
||||
|
||||
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
||||
|
||||
const statusLabel = (status) => statusMap[status] || "未知";
|
||||
|
||||
const getStatusStyle = (status) => {
|
||||
const styles = {
|
||||
"未分配": { color: '#909399', background: '#f4f4f5' },
|
||||
"进行中": { color: '#409EFF', background: '#ecf5ff' },
|
||||
"已完成": { color: '#67C23A', background: '#f0f9eb' },
|
||||
"已完结": { color: '#e6a23c', background: '#fdf6ec' }
|
||||
}
|
||||
return styles[status] || styles["未分配"];
|
||||
}
|
||||
0: { color: "#909399", background: "#f4f4f5" },
|
||||
1: { color: "#409EFF", background: "#ecf5ff" },
|
||||
2: { color: "#67C23A", background: "#f0f9eb" },
|
||||
3: { color: "#e6a23c", background: "#fdf6ec" },
|
||||
};
|
||||
return styles[status] || styles[0];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-container {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
/* 调整进度条内部文字大小 */
|
||||
:deep(.el-progress__text) {
|
||||
font-size: 11px !important;
|
||||
@@ -158,6 +187,7 @@ const getStatusStyle = (status) => {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.staff-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -166,6 +196,7 @@ const getStatusStyle = (status) => {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.staff-role {
|
||||
width: 34px;
|
||||
height: 100%;
|
||||
@@ -176,19 +207,34 @@ const getStatusStyle = (status) => {
|
||||
background: #909399;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.staff-name {
|
||||
padding-left: 6px;
|
||||
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.internal .staff-role { background: #409eff; }
|
||||
|
||||
.staff-item.client {
|
||||
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 {
|
||||
font-family: 'Monaco', monospace;
|
||||
font-family: "Monaco", monospace;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
@@ -197,9 +243,17 @@ const getStatusStyle = (status) => {
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
@@ -208,12 +262,20 @@ const getStatusStyle = (status) => {
|
||||
font-size: 11px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.time-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 11px;
|
||||
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>
|
||||
@@ -3,25 +3,36 @@
|
||||
<el-container class="full-screen-container">
|
||||
<el-aside width="200px" class="siderbar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-container">
|
||||
<img src="@/assets/logo.jpg" alt="方舟网络" class="logo-image" />
|
||||
<div class="text-content">
|
||||
<h2 class="company-name">方舟网络</h2>
|
||||
<p class="system-name">工单管理系统</p>
|
||||
<!-- 修复:将 logo 区域包装成一个正确的外部链接 -->
|
||||
<a
|
||||
href="https://www.example.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="logo-link"
|
||||
>
|
||||
<div class="logo-container">
|
||||
<img src="@/assets/logo.jpg" alt="方舟网络" class="logo-image" />
|
||||
<div class="text-content">
|
||||
<h2 class="company-name">方舟网络</h2>
|
||||
<p class="system-name">工单管理系统</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<el-menu default-active="/" class="sidebar-menu" router>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><icon-menu /></el-icon>
|
||||
<span>工单管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/stats">
|
||||
<el-icon>
|
||||
<document />
|
||||
</el-icon>
|
||||
<span>数据统计</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/customers">
|
||||
<el-icon>
|
||||
<User />
|
||||
@@ -66,6 +77,8 @@
|
||||
import { reactive, onMounted } from "vue";
|
||||
import { Menu as IconMenu, Document, User } from "@element-plus/icons-vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { resetRouter } from "@/router";
|
||||
import { userApi } from "@/api/ticket";
|
||||
|
||||
const router = useRouter();
|
||||
const user = reactive({
|
||||
@@ -82,10 +95,19 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
router.push("/login");
|
||||
const handleLogout = async () => {
|
||||
// 1. 调用后端接口销毁 session
|
||||
try {
|
||||
await userApi.logout();
|
||||
} catch (e) {
|
||||
// 即使后端接口异常,也要完成前端的清理
|
||||
}
|
||||
|
||||
localStorage.removeItem("user_info");
|
||||
sessionStorage.removeItem("user_info");
|
||||
|
||||
resetRouter();
|
||||
router.push({ name: "login" });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -119,6 +141,13 @@ const handleLogout = () => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 修复:为新添加的链接添加样式 */
|
||||
.logo-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,105 +1,220 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus' // 引入提示组件
|
||||
|
||||
|
||||
// 公开路由,所有用户都可以访问
|
||||
export const publicRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { isPublic: true }
|
||||
}
|
||||
]
|
||||
|
||||
// 动态路由,根据角色进行添加
|
||||
export const dynamicRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'admin_layout',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
meta: { role: 'admin' },
|
||||
redirect: { name: 'home' }, // 添加默认重定向
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/views/TicketManagement.vue'),
|
||||
meta: { title: '工单管理' }
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'stats',
|
||||
component: () => import('@/views/StatsView.vue'),
|
||||
meta: { title: '数据统计' }
|
||||
},
|
||||
{
|
||||
path: 'customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/views/CustomersManagement.vue'),
|
||||
meta: { title: '客户管理' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user_workdesk',
|
||||
component: () => import('@/views/UserView.vue'),
|
||||
meta: { role: 'user', title: '工作台' }
|
||||
},
|
||||
{
|
||||
path: '/client',
|
||||
name: 'client_monitor',
|
||||
component: () => import('@/views/ClientView.vue'),
|
||||
meta: { role: 'client', title: '客户监控' }
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('../views/TicketManagement.vue'),
|
||||
// 标记需要 admin 权限
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'stats',
|
||||
component: () => import('../views/StatsView.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: 'customers',
|
||||
name: 'customers',
|
||||
component: () => import('../views/CustomersManagement.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user_workdesk',
|
||||
component: () => import('../views/UserView.vue'),
|
||||
// 标记只需要普通用户权限
|
||||
meta: { requiresAuth: true, role: 'user' }
|
||||
},
|
||||
{
|
||||
path: '/client',
|
||||
name: 'client_monitor',
|
||||
component: () => import('../views/ClientView.vue'),
|
||||
// 标记只需要普通用户权限
|
||||
meta: { requiresAuth: true, role: 'client' }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue')
|
||||
},
|
||||
]
|
||||
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 字段来判断
|
||||
const userRole = userInfo?.username === 'admin' ? 'admin' : 'user';
|
||||
/* 获取用户信息 */
|
||||
function getUserInfo() {
|
||||
try {
|
||||
const userInfoStr = localStorage.getItem('user_info') || sessionStorage.getItem('user_info')
|
||||
return userInfoStr ? JSON.parse(userInfoStr) : null
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isRequiresAuth = to.matched.some(record => record.meta.requiresAuth);
|
||||
/* 角色首页映射 */
|
||||
const roleHomeMap = {
|
||||
admin: { name: 'home' },
|
||||
user: { name: 'user_workdesk' },
|
||||
client: { name: 'client_monitor' }
|
||||
};
|
||||
|
||||
// 1. 未登录拦截
|
||||
if (isRequiresAuth && !token) {
|
||||
next({ name: 'login', query: { redirect: to.fullPath } });
|
||||
return;
|
||||
|
||||
/* 获取角色的默认路由 */
|
||||
function getRoleHomeRoute(role) {
|
||||
return roleHomeMap[role?.toLowerCase()] || { path: '/login' }
|
||||
}
|
||||
|
||||
|
||||
// ======动态路由管理======
|
||||
|
||||
let hasAddedRoutes = false; // 标记是否已添加动态路由
|
||||
|
||||
/* 添加动态路由 */
|
||||
function addDynamicRoutes(role) {
|
||||
try {
|
||||
// 过滤当前角色可以访问的路由
|
||||
const accessibleRoutes = dynamicRoutes.filter(route => {
|
||||
return !route.meta?.role || route.meta.role === role;
|
||||
});
|
||||
|
||||
// 添加路由
|
||||
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');
|
||||
}
|
||||
|
||||
// 2. 已登录状态下的逻辑
|
||||
if (token) {
|
||||
// 如果想去登录页,根据角色重定向到对应首页
|
||||
if (to.name === 'login') {
|
||||
userRole === 'admin' ? next({ name: 'home' }) : next({ name: 'user_workdesk' });
|
||||
return;
|
||||
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 });
|
||||
}
|
||||
|
||||
// --- 核心修复:权限校验 ---
|
||||
// 检查目标路由是否定义了需要的角色
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 未登录
|
||||
return next();
|
||||
}
|
||||
|
||||
// 3. 正常放行
|
||||
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
|
||||
export default router;
|
||||
|
||||
49
src/utils/format.js
Normal file
49
src/utils/format.js
Normal 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}`
|
||||
}
|
||||
@@ -1,72 +1,52 @@
|
||||
import axios from 'axios'
|
||||
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
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// 自动获取 Token(兼顾两种存储方式)
|
||||
const token = localStorage.getItem('token') || sessionStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
function createService(basePath) {
|
||||
const service = axios.create({
|
||||
baseURL: `/api/${basePath}`,
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
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);
|
||||
service.interceptors.response.use(
|
||||
response => response.data,
|
||||
|
||||
|
||||
// 1. 判定是否为 Token 相关的认证错误
|
||||
// 包含 500 状态码且消息含有 token 字样(如 decode 失败、过期等)
|
||||
if (status === 401) {
|
||||
error => {
|
||||
const resData = error.response?.data
|
||||
const status = error.response?.status
|
||||
|
||||
if (!isRelogging) {
|
||||
isRelogging = true; // 锁定
|
||||
// session过期处理
|
||||
if (status === 401) {
|
||||
if (!isRelogging) {
|
||||
isRelogging = true
|
||||
|
||||
localStorage.removeItem('token');
|
||||
// 清除用户信息,防止 UserView.vue 的 computed 报错
|
||||
localStorage.removeItem('user_info');
|
||||
resetRouter()
|
||||
|
||||
ElMessage.error('登录已失效,请重新登录');
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
|
||||
router.push('/login').then(() => {
|
||||
// 2. 延迟解锁,确保页面跳转期间的所有并发请求都已返回并被此拦截器捕获
|
||||
setTimeout(() => {
|
||||
isRelogging = false;
|
||||
}, 3000);
|
||||
});
|
||||
router.push({
|
||||
name: 'login',
|
||||
query: { redirect: router.currentRoute.value.fullPath }
|
||||
}).finally(() => {
|
||||
isRelogging = false
|
||||
})
|
||||
}
|
||||
return new Promise(() => { })
|
||||
}
|
||||
|
||||
return new Promise(() => { });
|
||||
const msg = resData?.message || '服务器响应异常'
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
const msg = resData?.message || '服务器响应异常';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
export default service
|
||||
return service
|
||||
}
|
||||
|
||||
export const adminRequest = createService('admin')
|
||||
export const indexRequest = createService('index')
|
||||
export default adminRequest
|
||||
37
src/views/404View.vue
Normal file
37
src/views/404View.vue
Normal 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>
|
||||
@@ -114,7 +114,7 @@
|
||||
<el-table-column label="更新日期" width="140" align="center">
|
||||
<template #default="scope">
|
||||
<span class="date-text">{{
|
||||
formatDateOnly(
|
||||
formatDate(
|
||||
scope.row.lock_time || scope.row.update_time || scope.row.create_time
|
||||
)
|
||||
}}</span>
|
||||
@@ -143,8 +143,9 @@ import {
|
||||
CircleCheck,
|
||||
Monitor,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ticketApi } from "@/api/ticket";
|
||||
import { ticketApi, userApi } from "@/api/ticket";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { formatDate } from "@/utils/format";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
@@ -259,26 +260,18 @@ const getStatusClass = (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) => {
|
||||
page.value = p;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userApi.logout();
|
||||
} catch (e) {}
|
||||
|
||||
localStorage.removeItem("user_info");
|
||||
sessionStorage.removeItem("user_info");
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<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>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" class="ark-form">
|
||||
<el-form-item prop="username">
|
||||
<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 prop="password">
|
||||
@@ -64,17 +64,17 @@ import { ref, reactive } from "vue";
|
||||
import { DataBoard } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { userApi } from "@/api/ticket";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const route = useRoute();
|
||||
const loginForm = reactive({ username: "", password: "" });
|
||||
const rememberMe = ref(false);
|
||||
const loading = ref(false);
|
||||
const loginFormRef = ref(null);
|
||||
|
||||
const loginRules = reactive({
|
||||
username: [{ required: true, message: "请输入工员编号", trigger: "blur" }],
|
||||
username: [{ required: true, message: "请输入员工编号", trigger: "blur" }],
|
||||
password: [{ required: true, message: "请输入密钥", trigger: "blur" }],
|
||||
});
|
||||
|
||||
@@ -85,52 +85,32 @@ const handleLogin = async () => {
|
||||
loading.value = true;
|
||||
|
||||
const res = await userApi.login(loginForm.username, loginForm.password);
|
||||
// console.log(res)
|
||||
// 注意:这里由于你之前的封装,可能需要判断 res.data.code 还是 res.code
|
||||
// 如果用了拦截器,直接判断 res.code
|
||||
|
||||
if (res.code === 200) {
|
||||
const token = res.data.token;
|
||||
const userInfo = JSON.stringify(res.data.user_info);
|
||||
const role = res.data.user_info.role;
|
||||
// ThinkPHP 会自动通过 Set-Cookie 下发 session,浏览器自动接管,无需手动存 token
|
||||
const userInfo = res.data;
|
||||
const userInfoStr = JSON.stringify(userInfo);
|
||||
|
||||
// console.log(role)
|
||||
// 用户信息存前端,仅用于显示用户名和角色路由判断
|
||||
const storage = rememberMe.value ? localStorage : sessionStorage;
|
||||
storage.setItem("user_info", userInfoStr);
|
||||
|
||||
// --- 核心逻辑开始 ---
|
||||
if (rememberMe.value) {
|
||||
// 勾选了:存入 localStorage,实现持久化
|
||||
localStorage.setItem("token", token);
|
||||
localStorage.setItem("user_info", userInfo);
|
||||
} else {
|
||||
// 没勾选:存入 sessionStorage,关闭标签页即失效
|
||||
sessionStorage.setItem("token", token);
|
||||
sessionStorage.setItem("user_info", userInfo);
|
||||
}
|
||||
// --- 核心逻辑结束 ---
|
||||
ElMessage({
|
||||
message: "身份验证通过,方舟系统加载中...",
|
||||
type: "success",
|
||||
duration: 300,
|
||||
});
|
||||
|
||||
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);
|
||||
// 触发路由守卫,自动处理跳转
|
||||
await router.push(route.query.redirect || "/");
|
||||
} else {
|
||||
ElMessage.error(res.message || "登录失败");
|
||||
ElMessage.error(res.message || "登录失败,请检查账号和密码");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login Error:", error);
|
||||
if (error.name !== "ValidationError") {
|
||||
ElMessage.error("登录请求异常,请稍后重试");
|
||||
// console.error("登录错误:", error);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -53,11 +53,18 @@
|
||||
<div class="left">
|
||||
<el-button type="primary" @click="handleAdd" :icon="Plus">新增任务</el-button>
|
||||
</div>
|
||||
<TicketFilter :operators="operatorsForFilter" :staffs="staffList" :filters="filters"
|
||||
<TicketFilter :operators="operatorsForFilter" :staffs="userStaffList" :filters="filters"
|
||||
@filter-change="handleFilterChange" @reset="handleFilterReset" />
|
||||
</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">
|
||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
|
||||
@@ -66,14 +73,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TicketForm v-model="showAddDialog" :form-data="currentTicket" :customers="customerList"
|
||||
:customer-employees="allCustomerEmployees" :staffs="staffList" :is-editing="isEditing"
|
||||
@submit="handleFormSubmit" />
|
||||
<TicketForm v-model="showAddDialog" :form-data="currentTicket" :companiesWithOperators="companywithoperatorsList"
|
||||
:user-staffs="userStaffList" :is-editing="isEditing" @submit="handleFormSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from "vue";
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
Plus,
|
||||
@@ -85,7 +91,7 @@ import {
|
||||
} from "@element-plus/icons-vue";
|
||||
|
||||
// API 引入
|
||||
import { ticketApi, customerApi } from "@/api/ticket";
|
||||
import { ticketApi } from "@/api/ticket";
|
||||
|
||||
// 组件引入
|
||||
import TicketFilter from "@/components/ticket/TicketFilter.vue";
|
||||
@@ -94,14 +100,16 @@ import TicketForm from "@/components/ticket/TicketForm.vue";
|
||||
|
||||
// --- 响应式状态 ---
|
||||
const tickets = ref([]);
|
||||
const operators = ref([]);
|
||||
const staffList = ref([]);
|
||||
const customerList = ref([]);
|
||||
const allCustomerEmployees = 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,
|
||||
@@ -119,54 +127,56 @@ const pagination = reactive({
|
||||
|
||||
const filters = reactive({
|
||||
status: null,
|
||||
operator_name: null,
|
||||
staff_id: null,
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
search: null,
|
||||
sort_order: "desc",
|
||||
});
|
||||
|
||||
// --- 核心数据加载方法 ---
|
||||
/* 获取运营人员数据的方法*/
|
||||
|
||||
// 在 TicketManagement.vue 中利用 computed 提取
|
||||
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 () => {
|
||||
const fetchOperatorsForFilter = async () => {
|
||||
try {
|
||||
const res = await customerApi.getCustomers();
|
||||
// 适配点:根据返回的 code: 200 逻辑进行判断
|
||||
if (res.code === 200) {
|
||||
// 适配点:直接从 res 中获取 customers 和 employees 数组
|
||||
customerList.value = res.customers || [];
|
||||
allCustomerEmployees.value = res.employees || [];
|
||||
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);
|
||||
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 {
|
||||
const res = await ticketApi.getStaffs();
|
||||
if (res.code) {
|
||||
staffList.value = res.data;
|
||||
const res = await ticketApi.getUserStaffs();
|
||||
if (res.code === 200) {
|
||||
userStaffList.value = res.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取我方员工列表失败:", error);
|
||||
@@ -178,13 +188,26 @@ const loadStaffData = async () => {
|
||||
*/
|
||||
const initGlobalStats = async () => {
|
||||
try {
|
||||
const response = await ticketApi.getticketsstatuscount();
|
||||
if (response.code) {
|
||||
allStats.total = response.data["total"];
|
||||
allStats.pending = response.data["pending"];
|
||||
allStats.ongoing = response.data["ongoing"];
|
||||
allStats.completed = response.data["completed"];
|
||||
allStats.is_completed = response.data["is_completed"];
|
||||
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);
|
||||
@@ -224,18 +247,20 @@ const refreshAll = () => {
|
||||
const handleFilterChange = (newFilters) => {
|
||||
Object.assign(filters, newFilters);
|
||||
pagination.page = 1;
|
||||
selectedTickets.value = [];
|
||||
loadTickets();
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
Object.assign(filters, {
|
||||
status: null,
|
||||
operator_name: null,
|
||||
staff_id: null,
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
search: null,
|
||||
sort_order: "desc",
|
||||
});
|
||||
pagination.page = 1;
|
||||
selectedTickets.value = [];
|
||||
loadTickets();
|
||||
};
|
||||
|
||||
@@ -289,7 +314,6 @@ const handleDelete = async (id) => {
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
const res = await ticketApi.deleteTicket(id);
|
||||
// console.log('删除',res)
|
||||
if (res.code) {
|
||||
ElMessage.success("删除成功");
|
||||
refreshAll();
|
||||
@@ -299,15 +323,28 @@ const handleDelete = async (id) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 加载所有运营人员
|
||||
const loadOperators = async () => {
|
||||
const handleBatchFinish = async () => {
|
||||
const ids = selectedTickets.value.map((t) => t.id);
|
||||
try {
|
||||
const response = await ticketApi.getOperators();
|
||||
if (response.code) {
|
||||
operators.value = response.data;
|
||||
await ElMessageBox.confirm(
|
||||
`确认将选中的 ${ids.length} 个工单一键已完结?`,
|
||||
"批量操作",
|
||||
{ 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) {
|
||||
console.error("获取运营人员筛选列表失败:", error);
|
||||
if (error !== "cancel") ElMessage.error("批量操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,17 +352,18 @@ const handleSizeChange = (val) => {
|
||||
pagination.limit = val;
|
||||
loadTickets();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
pagination.page = val;
|
||||
loadTickets();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchOperatorsForFilter();
|
||||
loadTickets();
|
||||
initGlobalStats();
|
||||
loadOperators();
|
||||
loadStaffData();
|
||||
loadCustomerData(); // 级联数据加载
|
||||
loadUserStaffData();
|
||||
loadCompanyWithOperatorsData();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -445,6 +483,22 @@ onMounted(() => {
|
||||
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 {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
|
||||
@@ -85,12 +85,18 @@
|
||||
</template>
|
||||
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column
|
||||
prop="website"
|
||||
label="网站名称"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="website" label="网站名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
type="primary"
|
||||
: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">
|
||||
<template #default="scope">
|
||||
@@ -255,10 +261,11 @@
|
||||
<template #default="scope">
|
||||
<span class="pure-date">
|
||||
{{
|
||||
(["已完成", "已完结"].includes(currentStatus)
|
||||
? scope.row.lock_time
|
||||
: scope.row.create_time
|
||||
)?.slice(0, 10) || "-"
|
||||
formatDate(
|
||||
["已完成", "已完结"].includes(currentStatus)
|
||||
? scope.row.lock_time
|
||||
: scope.row.create_time
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
@@ -319,7 +326,9 @@ import {
|
||||
Checked,
|
||||
Loading,
|
||||
} 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 loading = ref(false);
|
||||
@@ -377,7 +386,6 @@ const fetchTickets = async () => {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
console.log("员工id", realStaffInfo.value.id);
|
||||
params.staff_id = realStaffInfo.value.id;
|
||||
}
|
||||
|
||||
@@ -511,10 +519,16 @@ const handleCancel = (row) => {
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
ElMessageBox.confirm("确定退出当前账号?", "提示", { type: "warning" }).then(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
router.push("/login");
|
||||
ElMessageBox.confirm("确定退出当前账号?", "提示", { type: "warning" }).then(async () => {
|
||||
try {
|
||||
await userApi.logout();
|
||||
} catch (e) {}
|
||||
|
||||
localStorage.removeItem("user_info");
|
||||
sessionStorage.removeItem("user_info");
|
||||
|
||||
resetRouter();
|
||||
router.push({ name: "login" });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -19,9 +19,10 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 8080, // 前端启动的端口
|
||||
proxy: {
|
||||
'/api': { // 所有以api开头的请求都会被代理
|
||||
'/api': {
|
||||
target: 'http://localhost',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user