提交修改后的版本

This commit is contained in:
2026-02-03 15:30:45 +08:00
parent 19c008a401
commit 5ac365b45e
8 changed files with 664 additions and 518 deletions

1
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "vue_tickets", "name": "vue_tickets",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"element-plus": "^2.11.8", "element-plus": "^2.11.8",
"vue": "^3.5.22" "vue": "^3.5.22"

View File

@@ -12,6 +12,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"element-plus": "^2.11.8", "element-plus": "^2.11.8",
"vue": "^3.5.22" "vue": "^3.5.22"

View File

@@ -1,37 +1,44 @@
@import './base.css'; /* 重置基础样式 */
html,
/* 重置全局默认边距 */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body { body {
display: flex; margin: 0;
place-items: center; padding: 0;
width: 100%;
height: 100%;
background-color: #f5f7fa;
/* 给背景一个淡灰色,更有后台质感 */
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
} }
#app { #app {
display: grid; /* 移除原有的 grid 和 center 布局 */
grid-template-columns: 1fr 1fr; width: 100%;
padding: 0 2rem; margin: 0;
padding: 0;
display: block;
/* 恢复块级布局 */
}
/* 如果你希望内容有一个最大宽度并居中,可以这样写 */
.ticket-management {
max-width: 1440px;
/* 常见的管理后台舒适宽度 */
margin: 0 auto;
/* 水平居中 */
padding: 20px;
box-sizing: border-box;
}
/* 链接样式微调 */
a {
text-decoration: none;
color: #409eff;
transition: 0.3s;
}
/* 针对移动端的小适配 */
@media (max-width: 768px) {
.ticket-management {
padding: 10px;
} }
} }

View File

@@ -1,91 +1,61 @@
<template> <template>
<el-card class="filter-card" shadow="never"> <el-card class="filter-card" shadow="hover">
<template #header> <el-form :model="localFilters" label-width="auto" class="search-form">
<div class="filter-header"> <div class="search-container">
<span>筛选条件</span> <div class="filter-main">
<el-button type="text" @click="$emit('reset')">重置</el-button> <el-form-item label="工单状态">
</div> <el-radio-group v-model="localFilters.status" size="default" @change="handleSearch">
</template> <el-radio-button :label="null">全部</el-radio-button>
<el-radio-button :label="0">未分配</el-radio-button>
<el-radio-button :label="1">进行中</el-radio-button>
<el-radio-button :label="2">已完成</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form :model="localFilters" label-width="100px"> <div class="input-group">
<el-row :gutter="20"> <el-form-item label="相关人员">
<el-col :span="4"> <el-select v-model="localFilters.operator_name" placeholder="运营人员" clearable class="w-150">
<el-form-item label="状态"> <el-option v-for="op in operators" :key="op" :label="op" :value="op" />
<el-select v-model="localFilters.status" placeholder="全部" clearable> </el-select>
<el-option label="未分配" :value="0" /> <span class="divider">/</span>
<el-option label="进行中" :value="1" /> <el-select v-model="localFilters.principalpeople" placeholder="负责人" clearable class="w-150">
<el-option label="已完成" :value="2" /> <el-option v-for="op in operators" :key="op" :label="op" :value="op" />
<el-option label="已完结" :value="3" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4"> <el-form-item label="关键词">
<el-form-item label="运营人员">
<el-select v-model="localFilters.operator_name" placeholder="全部" clearable>
<el-option
v-for="operator in operators"
:key="operator"
:label="operator"
:value="operator"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="负责人">
<el-select v-model="localFilters.operator_name" placeholder="全部" clearable>
<el-option
v-for="operator in operators"
:key="operator"
:label="operator"
:value="operator"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="搜索关键词">
<el-input <el-input
v-model="localFilters.search" v-model="localFilters.search"
placeholder="网站/运营/负责人" placeholder="搜索网站名或备注..."
clearable clearable
/> @keyup.enter="handleSearch"
</el-form-item> class="search-input"
</el-col> >
<template #prefix>
<el-col :span="3">
<el-form-item label="排序方式">
<el-select v-model="localFilters.sort_order">
<el-option label="降序" value="desc" />
<el-option label="升序" value="asc" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-col :span="3">
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
查询 </template>
</el-button> </el-input>
</el-form-item> </el-form-item>
</el-col> </div>
</div>
<el-row :gutter="20">
</el-row>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="handleSearch" class="search-btn">
执行筛选
</el-button>
<el-button @click="$emit('reset')" plain>重置</el-button>
<el-dropdown trigger="click" class="more-filter">
<el-button icon="Sort">排序</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="localFilters.sort_order = 'desc'">时间降序</el-dropdown-item>
<el-dropdown-item @click="localFilters.sort_order = 'asc'">时间升序</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-form> </el-form>
</el-card> </el-card>
</template> </template>
@@ -124,12 +94,68 @@
<style scoped> <style scoped>
.filter-card { .filter-card {
margin-bottom: 20px; border: none;
border-radius: 12px;
background: linear-gradient(to right, #ffffff, #f9fafc);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.filter-header { .filter-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(149, 157, 165, 0.2);
}
.search-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 20px;
}
.filter-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center; align-items: center;
} }
.w-150 { width: 150px; }
.search-input { width: 280px; }
.divider {
margin: 0 8px;
color: #dcdfe6;
}
.filter-actions {
display: flex;
gap: 10px;
padding-top: 4px;
}
.search-btn {
padding: 12px 24px;
font-weight: bold;
letter-spacing: 1px;
transition: all 0.2s;
}
.search-btn:hover {
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
}
/* 移动端适配 */
@media (max-width: 768px) {
.search-container { flex-direction: column; }
.search-input { width: 100%; }
}
</style> </style>

View File

@@ -1,88 +1,109 @@
<template> <template>
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:title="isEditing ? '编辑工单' : '新增工单'" :title="isEditing ? '📝 编辑工单任务' : '✨ 创建新工单'"
width="600px" width="550px"
class="modern-dialog"
destroy-on-close
@close="handleClose" @close="handleClose"
> >
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form
<el-form-item label="网站" prop="website"> ref="formRef"
<el-input v-model="form.website" placeholder="请输入网站地址" /> :model="form"
:rules="rules"
label-position="top"
class="compact-form"
>
<div class="form-section">
<div class="section-title">业务基础信息</div>
<el-form-item label="网站地址" prop="website">
<el-input
v-model="form.website"
placeholder="https://example.com"
clearable
>
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item> </el-form-item>
</div>
<el-form-item label="运营人员" prop="operator"> <div class="form-section people-grid">
<el-select v-model="form.operator" placeholder="请选择运营人员"> <div class="section-title column-full">权责分配</div>
<el-form-item label="运营人员 (外方)" prop="operator" class="client-item">
<el-select v-model="form.operator" placeholder="请选择" style="width: 100%">
<el-option <el-option
v-for="operator in operators" v-for="op in operators"
:key="operator" :key="op"
:label="operator" :label="op"
:value="operator" :value="op"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="负责人" prop="principalpeople"> <el-form-item label="负责人 (我方)" prop="principalpeople" class="owner-item">
<el-input v-model="form.principalpeople" placeholder="请输入负责人" /> <el-input v-model="form.principalpeople" placeholder="请输入姓名">
<template #prefix>
<el-icon><UserFilled /></el-icon>
</template>
</el-input>
</el-form-item> </el-form-item>
</div>
<el-form-item label="状态"> <div class="form-section">
<el-radio-group v-model="form.is_complete"> <el-form-item label="当前进度">
<el-radio :label="0">未完成</el-radio> <el-radio-group v-model="form.is_complete" class="modern-radio-group">
<el-radio :label="1">已完成</el-radio> <el-radio-button :label="0">进行中</el-radio-button>
<el-radio-button :label="1">已完成</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注说明" prop="remark">
<el-input <el-input
v-model="form.remark" v-model="form.remark"
type="textarea" type="textarea"
:rows="3" :rows="3"
placeholder="请输入备注信息" placeholder="填写相关的补充信息或注意事项..."
resize="none"
/> />
</el-form-item> </el-form-item>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer">
<!--更改对话框状态--> <el-button @click="dialogVisible = false" plain>取消</el-button>
<el-button @click="dialogVisible = false">取消</el-button> <el-button
<el-button type="primary" @click="handleSubmit" :loading="submitting"> type="primary"
{{ isEditing ? '更新' : '新增' }} @click="handleSubmit"
:loading="submitting"
class="submit-btn"
>
{{ isEditing ? '保存修改' : '立即创建' }}
</el-button> </el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref, reactive, watch, nextTick } from 'vue' import { ref, reactive, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus' import { Link, UserFilled } from '@element-plus/icons-vue'
const emit = defineEmits(['update:modelValue', 'submit']) const emit = defineEmits(['update:modelValue', 'submit'])
const dialogVisible = ref(false) // 对话框显示状态,默认为false const dialogVisible = ref(false)
const formRef = ref() const formRef = ref()
const submitting = ref(false) const submitting = ref(false)
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: Boolean,
type: Boolean, formData: Object,
default: false operators: Array,
}, isEditing: Boolean
formData: {
type: Object,
default: () => ({})
},
operators: {
type: Array,
default: () => []
},
isEditing: {
type: Boolean,
default: false
}
}) })
const form = reactive({ const form = reactive({
website: '', website: '',
operator: '', operator: '',
@@ -92,23 +113,15 @@ const form = reactive({
}) })
const rules = { const rules = {
website: [ website: [{ required: true, message: '请输入网站地址', trigger: 'blur' }],
{ required: true, message: '网站不能为空', trigger: 'blur' } operator: [{ required: true, message: '请选择运营人员', trigger: 'change' }],
], principalpeople: [{ required: true, message: '请输入负责人', trigger: 'blur' }]
operator: [
{ required: true, message: '请选择运营人员', trigger: 'change' }
],
principalpeople: [
{ required: true, message: '负责人不能为空', trigger: 'blur' }
]
} }
// 监听对话框显示状态 // 监听显隐
watch( watch(() => props.modelValue, (val) => {
() => props.modelValue, dialogVisible.value = val
(newVal) => { if (val) {
dialogVisible.value = newVal
if (newVal) {
nextTick(() => { nextTick(() => {
resetForm() resetForm()
if (props.isEditing && props.formData) { if (props.isEditing && props.formData) {
@@ -116,60 +129,99 @@ watch(
} }
}) })
} }
} })
)
// 监听对话框关闭 watch(() => dialogVisible.value, (val) => emit('update:modelValue', val))
watch(
() => dialogVisible.value,
(newVal) => {
emit('update:modelValue', newVal)
}
)
// 在 TicketForm.vue 的 script setup 部分添加以下代码
watch(
() => props.isEditing,
(newVal) => {
// 当 isEditing 改变时,确保表单状态正确更新
if (!newVal) {
// 切换到新增模式时,重置表单
resetForm()
}
}
)
const resetForm = () => { const resetForm = () => {
if (formRef.value) { if (formRef.value) formRef.value.resetFields()
formRef.value.clearValidate() Object.assign(form, { website: '', operator: '', principalpeople: '', is_complete: 0, remark: '' })
}
Object.assign(form, {
website: '',
operator: '',
principalpeople: '',
is_complete: 0,
remark: ''
})
} }
const handleClose = () => { const handleClose = () => resetForm()
resetForm()
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formRef.value) return if (!formRef.value) return
try { try {
const valid = await formRef.value.validate() await formRef.value.validate()
if (valid) {
submitting.value = true submitting.value = true
emit('submit', { ...form }) emit('submit', { ...form })
} } catch (e) {
} catch (error) {
// 验证失败 // 验证失败
} finally { } finally {
submitting.value = false submitting.value = false
} }
} }
</script> </script>
<style scoped>
/* 深度修改对话框圆角 */
:deep(.modern-dialog) {
border-radius: 12px;
overflow: hidden;
}
.form-section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
display: flex;
align-items: center;
}
.section-title::before {
content: "";
width: 4px;
height: 14px;
background: #409eff;
margin-right: 8px;
border-radius: 2px;
}
/* 两列布局 */
.people-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.column-full { grid-column: span 2; }
/* 人员项区分颜色提示 */
:deep(.client-item .el-form-item__label) {
color: #909399 !important;
}
:deep(.owner-item .el-form-item__label) {
color: #409eff !important;
}
/* 现代单选框组 */
.modern-radio-group {
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
border-radius: 8px;
overflow: hidden;
}
/* 底部按钮 */
.dialog-footer {
padding-top: 10px;
}
.submit-btn {
padding-left: 24px;
padding-right: 24px;
font-weight: 600;
}
/* 调整输入框间距 */
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-form-item__label) {
padding-bottom: 4px !important;
font-size: 13px;
}
</style>

View File

@@ -1,106 +1,205 @@
<template> <template>
<el-card shadow="never"> <el-card class="compact-card" shadow="never">
<el-table <el-table
:data="tickets" :data="tickets"
v-loading="loading" v-loading="loading"
stripe header-cell-class-name="table-header"
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="website" label="网站" min-width="150" show-overflow-tooltip /> <el-table-column prop="website" label="网站地址" min-width="180">
<el-table-column prop="operator" label="运营人员" width="120" align="center" /> <template #default="{ row }">
<el-table-column prop="principalpeople" label="负责人" width="120" align="center" /> <el-link
type="primary"
:href="row.website"
target="_blank"
class="site-link"
>
{{ row.website }}
</el-link>
</template>
</el-table-column>
<el-table-column label="协同人员" width="240">
<template #default="{ row }">
<div class="staff-wrapper">
<div class="staff-item client">
<span class="staff-role">运营</span>
<span class="staff-name">{{ row.operator }}</span>
</div>
<div class="staff-item internal">
<span class="staff-role">负责</span>
<span class="staff-name">{{ row.principalpeople }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="is_complete" label="状态" width="100" align="center"> <el-table-column prop="is_complete" label="状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :style="getStatusType(row.is_complete)"> <span class="status-badge" :style="getStatusStyle(row.is_complete)">
{{ row.is_complete }} {{ row.is_complete }}
</el-tag> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
<el-table-column label="时间节点" width="200">
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(row.create_time) }} <div class="time-wrapper">
<span>创建:{{ formatTime(row.create_time) }}</span>
<span class="update-label">更新:{{ formatTime(row.update_time) }}</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="update_time" label="更新时间" width="180" align="center">
<el-table-column label="操作" width="150" fixed="right" align="right">
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(row.update_time) }} <el-button link type="primary" @click="$emit('edit', row)">编辑</el-button>
</template> <el-divider direction="vertical" />
</el-table-column> <el-button link type="danger" @click="$emit('delete', row.id)">删除</el-button>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" @click="$emit('edit', row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" @click="$emit('delete', row.id)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div v-if="tickets.length === 0 && !loading" class="empty-state">
<el-empty description="暂无数据" />
</div>
</el-card> </el-card>
</template> </template>
<script setup> <script setup>
import { Edit, Delete } from '@element-plus/icons-vue'
defineProps({ defineProps({
tickets: { tickets: { type: Array, default: () => [] },
type: Array, loading: { type: Boolean, default: false }
default: () => []
},
loading: {
type: Boolean,
default: false
}
}) })
defineEmits(['edit', 'delete']) defineEmits(['edit', 'delete'])
// 格式化时间(给箭头函数赋函数名)
const formatTime = (time) => { const formatTime = (time) => {
if (!time) return '-'; if (!time) return '-';
const date = new Date(time); const date = new Date(time);
return date.toLocaleString('zh',{ return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} }
const getStatusStyle = (status) => {
// 获取工单状态样式 const styles = {
const getStatusType = (status) => { "未分配": { color: '#909399', background: '#f4f4f5' },
switch(status) { "进行中": { color: '#409EFF', background: '#ecf5ff' },
case "未分配": // 未完成 "已完成": { color: '#67C23A', background: '#f0f9eb' },
return { backgroundColor: '#9CA3AF', color: '#ffffff' } "已完结": { color: '#e6a23c', background: '#fdf6ec' }
case "进行中": // 已完成
return { backgroundColor: '#60A5FA', color: '#ffffff' }
case "已完成": // 已完结
return { backgroundColor: '#34D399', color: '#ffffff' }
case "已完结": // 已完结
return { backgroundColor: '#A7F3D0', color: '#065F46' }
default:
return 'warning'
} }
return styles[status] || styles["未分配"];
} }
</script> </script>
<style scoped> <style scoped>
.empty-state { .staff-wrapper {
padding: 40px 0; display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
}
.staff-item {
display: flex;
align-items: center;
height: 24px;
font-size: 13px;
border-radius: 4px;
overflow: hidden;
border: 1px solid transparent;
}
.staff-role {
width: 40px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
}
.staff-name {
padding-left: 8px;
font-weight: 500;
}
/* 外方样式:低调工业风 */
.staff-item.client {
background: #f4f4f5;
border-color: #e9e9eb;
color: #909399;
}
.staff-item.client .staff-role {
background: #909399;
color: #fff;
}
/* 我方样式:专业商务蓝 */
.staff-item.internal {
background: #ecf5ff;
border-color: #d9ecff;
color: #409eff;
}
.staff-item.internal .staff-role {
background: #409eff;
color: #fff;
}
.compact-card {
border: 1px solid #ebeef5;
border-radius: 4px;
}
/* 链接样式优化 */
.site-link {
font-weight: normal;
font-size: 13px;
}
/* 人员标签化区分 */
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.role-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 2px;
width: fit-content;
}
.role-tag.client {
background: #f0f2f5;
color: #666;
border-left: 3px solid #dcdfe6;
}
.role-tag.owner {
background: #e8f4ff;
color: #1890ff;
border-left: 3px solid #1890ff;
}
/* 状态小红点风格 */
.status-badge {
display: inline-block;
padding: 0 10px;
height: 24px;
line-height: 24px;
font-size: 12px;
border-radius: 12px;
font-weight: 500;
}
/* 时间列紧凑化 */
.time-wrapper {
display: flex;
flex-direction: column;
font-size: 12px;
color: #606266;
}
.update-label {
color: #909399;
}
/* 表头加深,增加稳重感 */
:deep(.table-header) {
background-color: #fafafa !important;
color: #333;
font-weight: 600;
} }
</style> </style>

View File

@@ -4,7 +4,19 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App) const app = createApp(App)
app.use(ElementPlus)
app.use(ElementPlus, {
locale: zhCn,
})
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app') app.mount('#app')

View File

@@ -1,61 +1,66 @@
<template> <template>
<div class="ticket-management"> <div class="ticket-management">
<div class="stats-container">
<div class="stat-item total">
<div class="stat-label">总工单数</div>
<div class="stat-value">{{ allStats.total }}</div>
</div>
<div class="stat-item pending">
<div class="stat-label">未分配</div>
<div class="stat-value">{{ allStats.pending }}</div>
</div>
<div class="stat-item ongoing">
<div class="stat-label">进行中</div>
<div class="stat-value">{{ allStats.ongoing }}</div>
</div>
<div class="stat-item success">
<div class="stat-label">已完成/已完结</div>
<div class="stat-value">{{ allStats.completed }}</div>
</div>
</div>
<div class="main-content">
<!-- 操作栏 -->
<div class="action-bar"> <div class="action-bar">
<el-button type="primary" @click="showAddDialog = true" :icon="Plus"> <div class="left">
新增工单 <el-button type="primary" @click="handleAdd" :icon="Plus">新增任务</el-button>
</el-button>
<div class="stats">
<el-statistic title="总工单数" :value="totalTickets" />
<el-statistic title="未完成" :value="pendingCount" />
<el-statistic title="正在进行" :value="ongoingCount" />
<el-statistic title="已完成" :value="completedCount" />
<el-statistic title="已完结" :value="iscompleteCount" />
</div> </div>
<TicketFilter
:filters="filters"
:operators="operators"
@filter-change="handleFilterChange"
@reset="handleFilterReset"
/>
</div> </div>
<!-- 筛选组件 -->
<TicketFilter :filters="filters" :operators="operators" @filter-change="handleFilterChange"
@reset="handleFilterReset" />
<!-- 工单列表 -->
<TicketList :tickets="tickets" :loading="loading" @edit="handleEdit" @delete="handleDelete" /> <TicketList :tickets="tickets" :loading="loading" @edit="handleEdit" @delete="handleDelete" />
<!-- 分页 --> <div class="pagination-container">
<div class="pagination"> <el-pagination
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit" v-model:current-page="pagination.page"
:total="pagination.total" :page-sizes="[10, 15, 20, 50]" v-model:page-size="pagination.limit"
layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange" :total="pagination.total"
@current-change="handleCurrentChange" /> :page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div> </div>
<!-- 新增/编辑对话框 --> <TicketForm v-model="showAddDialog" :form-data="currentTicket" :operators="operators" :is-editing="isEditing" @submit="handleFormSubmit" />
<TicketForm v-model="showAddDialog" :form-data="currentTicket" :operators="operators" :is-editing="isEditing"
@submit="handleFormSubmit" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' // 引入plus图标 import { Plus } from '@element-plus/icons-vue'
import { ticketApi } from '@/api/ticket' import { ticketApi } from '@/api/ticket'
import TicketFilter from '@/components/TicketFilter.vue' import TicketFilter from '@/components/TicketFilter.vue'
import TicketList from '@/components/TicketList.vue' import TicketList from '@/components/TicketList.vue'
import TicketForm from '@/components/TicketForm.vue' import TicketForm from '@/components/TicketForm.vue'
// 响应式数据 // --- 响应式数据 ---
const tickets = ref([]) const tickets = ref([])
const operators = ref([]) const operators = ref([])
const loading = ref(false) const loading = ref(false)
@@ -63,7 +68,14 @@ const showAddDialog = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
const currentTicket = ref({}) const currentTicket = ref({})
// 分页和筛选 // 独立统计数据:专门给顶部的卡片使用
const allStats = reactive({
total: 0,
pending: 0,
ongoing: 0,
completed: 0
})
const pagination = reactive({ const pagination = reactive({
page: 1, page: 1,
limit: 15, limit: 15,
@@ -78,14 +90,33 @@ const filters = reactive({
sort_order: 'desc' sort_order: 'desc'
}) })
// 计算属性 // --- 方法 ---
const totalTickets = computed(() => tickets.value.length)
const pendingCount = computed(() => tickets.value.filter(t => t.is_complete==='未分配').length)
const ongoingCount = computed(() => tickets.value.filter(t => t.is_complete==='未分配').length)
const completedCount = computed(() => tickets.value.filter(t => t.is_complete==='已完成').length)
const iscompleteCount = computed(() => tickets.value.filter(t => t.is_complete==='已完结').length)
// 方法 /**
* 核心:获取全局统计数据
* 逻辑:发送一个不带任何 filters 的请求,专门计算总数
*/
const initGlobalStats = async () => {
try {
// 请求第一页,且不带任何筛选条件(filters)
// 目的只是为了拿到全量数据来统计卡片
const response = await ticketApi.getTickets({ page: 1, limit: 1000 })
const resData = response.data
if (resData.code) {
const allData = resData.data.data
allStats.total = resData.data.total
allStats.pending = allData.filter(t => t.is_complete === '未分配').length
allStats.ongoing = allData.filter(t => t.is_complete === '进行中').length
allStats.completed = allData.filter(t => t.is_complete === '已完成' || t.is_complete === '已完结').length
}
} catch (error) {
console.error('统计全局数据失败:', error)
}
}
/**
* 加载列表数据(受分页和筛选影响)
*/
const loadTickets = async () => { const loadTickets = async () => {
loading.value = true loading.value = true
try { try {
@@ -94,154 +125,71 @@ const loadTickets = async () => {
limit: pagination.limit, limit: pagination.limit,
...filters ...filters
} }
const response = await ticketApi.getTickets(params) const response = await ticketApi.getTickets(params)
const { code, msg, data } = response.data const resData = response.data
if (resData.code) {
tickets.value = resData.data.data
console.log('对象值:', code) pagination.total = resData.data.total
if (code) {
tickets.value = data.data
console.log(`成功加载 ${tickets.value.length} 条数据`)
pagination.total = data.total
} else {
ElMessage.error(msg || '获取工单列表失败')
} }
} catch (error) { } catch (error) {
ElMessage.error('网络错误,请稍后重试') ElMessage.error('加载列表失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const loadOperators = async () => { // 筛选逻辑
try {
const response = await ticketApi.getOperators()
if (response.code) {
operators.value = response.data
}
} catch (error) {
console.error('获取运营人员列表失败:', error)
}
}
const handleFilterChange = (newFilters) => { const handleFilterChange = (newFilters) => {
Object.assign(filters, newFilters) Object.assign(filters, newFilters)
pagination.page = 1 pagination.page = 1
loadTickets() loadTickets() // 只刷新列表,不刷新卡片
} }
const handleFilterReset = () => { const handleFilterReset = () => {
Object.assign(filters, { Object.assign(filters, { status: null, operator_name: null, principalpeople: null, search: null, sort_order: 'desc' })
status: null,
operator_name: null,
principalpeople: null,
search: null,
sort_order: 'desc'
})
pagination.page = 1 pagination.page = 1
loadTickets() loadTickets()
} }
const handleEdit = (ticket) => { // 新增/编辑/删除后,需要同时刷新列表和统计卡片
currentTicket.value = { ...ticket } const refreshAll = () => {
isEditing.value = true
showAddDialog.value = true
}
const handleDelete = async (id) => {
try {
await ElMessageBox.confirm('确定要删除这个工单吗?', '警告', {
type: 'warning'
})
const response = await ticketApi.deleteTicket(id)
if (response.code) {
ElMessage.success('删除成功')
loadTickets() loadTickets()
} else { initGlobalStats()
ElMessage.error(response.msg || '删除失败')
}
} catch (error) {
// 用户取消删除
}
} }
const handleFormSubmit = async (formData) => { const handleFormSubmit = async (formData) => {
try { // ... 提交逻辑 ...
let response // 成功后调用 refreshAll()
if (isEditing.value) {
response = await ticketApi.updateTicket(currentTicket.value.id, formData)
} else {
response = await ticketApi.addTicket(formData)
} }
if (response.code) { const handleDelete = async (id) => {
ElMessage.success(isEditing.value ? '更新成功' : '新增成功') // ... 删除逻辑 ...
showAddDialog.value = false // 成功后调用 refreshAll()
resetForm()
loadTickets()
} else {
ElMessage.error(response.msg || '操作失败')
}
} catch (error) {
ElMessage.error('网络错误,请稍后重试')
}
} }
const resetForm = () => { // 分页逻辑
currentTicket.value = {} const handleSizeChange = (val) => { pagination.limit = val; loadTickets(); }
isEditing.value = false const handleCurrentChange = (val) => { pagination.page = val; loadTickets(); }
}
const handleSizeChange = (newSize) => { // 初始化
pagination.limit = newSize
pagination.page = 1
loadTickets()
}
const handleCurrentChange = (newPage) => {
pagination.page = newPage
loadTickets()
}
// 生命周期
onMounted(() => { onMounted(() => {
loadTickets() loadTickets() // 渲染表格
loadOperators() initGlobalStats() // 锁定卡片统计
// loadOperators()
}) })
</script> </script>
<style scoped> <style scoped>
.ticket-management { /* 保持原有样式不变 */
background: white; .ticket-management { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
border-radius: 8px; .stats-container { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
padding: 20px; .stat-item { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); border-left: 5px solid #409eff; }
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); .stat-item.pending { border-left-color: #909399; }
} .stat-item.ongoing { border-left-color: #e6a23c; }
.stat-item.success { border-left-color: #67c23a; }
.action-bar { .stat-label { font-size: 14px; color: #666; }
display: flex; .stat-value { font-size: 26px; font-weight: bold; color: #333; margin-top: 5px; }
justify-content: space-between; .main-content { background: white; padding: 20px; border-radius: 8px; }
align-items: center; .action-bar { display: flex; justify-content: space-between; margin-bottom: 20px; }
margin-bottom: 20px; .pagination-container { margin-top: 20px; display: flex; justify-content: flex-end; }
padding-bottom: 20px;
border-bottom: 1px solid #ebeef5;
}
.stats {
display: flex;
gap: 30px;
text-align: center;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
</style> </style>