web工单处理

This commit is contained in:
2025-12-28 13:18:28 +08:00
parent 1148e3368d
commit 7de30b1b36
12 changed files with 1209 additions and 95 deletions

View File

@@ -0,0 +1 @@
export { default as WorkcaseAssign } from './workcase/WorkcaseAssign.vue'

View File

@@ -0,0 +1,245 @@
<template>
<ElDialog
v-model="visible"
:title="dialogTitle"
width="500px"
@close="handleClose"
>
<div class="assign-form">
<div class="form-item">
<label class="form-label">选择工程师 <span class="required">*</span></label>
<ElSelect
v-model="form.processor"
placeholder="请选择工程师"
style="width: 100%;"
filterable
:loading="loadingEngineers"
>
<ElOption
v-for="engineer in availableEngineers"
:key="engineer.userId"
:label="`${engineer.username} (${engineer.statusName || '未知状态'})`"
:value="engineer.userId"
>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ engineer.username }}</span>
<span style="color: #999; font-size: 12px;">
{{ engineer.statusName }} | 当前{{ engineer.currentWorkload || 0 }}
</span>
</div>
</ElOption>
</ElSelect>
</div>
<div class="form-item" style="margin-top: 16px;">
<label class="form-label">备注说明可选</label>
<ElInput
v-model="form.message"
type="textarea"
:rows="3"
placeholder="请输入指派/转派说明..."
maxlength="200"
/>
</div>
<div class="form-item" style="margin-top: 16px;">
<label class="form-label">附件可选</label>
<FileUpload
ref="fileUploadRef"
mode="content"
:max-count="5"
:max-size="10 * 1024 * 1024"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
:auto-upload="false"
:custom-upload="handleFilesUpload"
@upload-error="handleUploadError"
/>
</div>
</div>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitting">确定</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElDialog, ElButton, ElInput, ElSelect, ElOption, ElMessage } from 'element-plus'
import { FileUpload } from 'shared/components'
import { fileAPI } from 'shared/api'
import { workcaseAPI } from '@/api/workcase'
import { workcaseChatAPI } from '@/api/workcase/workcaseChat'
import type { TbWorkcaseProcessDTO } from '@/types/workcase/workcase'
import type { CustomerServiceVO } from '@/types/workcase/customer'
import type { TbSysFileDTO } from 'shared/types'
interface Props {
/** 是否显示弹窗 */
modelValue: boolean
/** 工单ID */
workcaseId: string
/** 当前处理人ID转派时排除 */
currentProcessor?: string
/** 操作类型assign-指派redeploy-转派 */
action?: 'assign' | 'redeploy'
}
const props = withDefaults(defineProps<Props>(), {
currentProcessor: '',
action: 'assign'
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'success': []
}>()
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 弹窗标题
const dialogTitle = computed(() => {
return props.action === 'assign' ? '指派工程师' : '转派工程师'
})
// 表单数据
const form = ref({
processor: '',
message: '',
fileIds: [] as string[]
})
// 状态
const submitting = ref(false)
const loadingEngineers = ref(false)
const availableEngineers = ref<CustomerServiceVO[]>([])
const fileUploadRef = ref<InstanceType<typeof FileUpload>>()
// 监听弹窗打开,加载工程师列表
watch(visible, async (val) => {
if (val) {
// 重置表单
form.value = { processor: '', message: '', fileIds: [] }
if (fileUploadRef.value) {
fileUploadRef.value.clearFiles()
}
// 加载工程师列表
await loadAvailableEngineers()
}
})
// 加载可用工程师列表
const loadAvailableEngineers = async () => {
loadingEngineers.value = true
try {
const res = await workcaseChatAPI.getCustomerServicePage({
filter: {},
pageParam: { page: 1, pageSize: 100 }
})
if (res.success && res.dataList) {
// 排除当前处理人
availableEngineers.value = res.dataList.filter(
(engineer: CustomerServiceVO) => engineer.userId !== props.currentProcessor
)
}
} catch (error) {
console.error('加载工程师列表失败:', error)
ElMessage.error('加载工程师列表失败')
} finally {
loadingEngineers.value = false
}
}
// 自定义文件上传
const handleFilesUpload = async (files: File[]): Promise<TbSysFileDTO[]> => {
const uploadedFiles: TbSysFileDTO[] = []
for (const file of files) {
const result = await fileAPI.uploadFile({
file,
module: 'workcase',
optsn: props.workcaseId
})
if (result.success && result.data) {
uploadedFiles.push(result.data)
if (result.data.fileId) {
form.value.fileIds.push(result.data.fileId)
}
} else {
throw new Error(`文件 ${file.name} 上传失败`)
}
}
return uploadedFiles
}
// 上传错误处理
const handleUploadError = (error: string) => {
ElMessage.error(error)
}
// 提交
const handleSubmit = async () => {
if (!form.value.processor) {
ElMessage.warning('请选择工程师')
return
}
if (!props.workcaseId) {
ElMessage.error('工单ID不存在')
return
}
submitting.value = true
try {
// 如果有待上传的文件,先上传
if (fileUploadRef.value?.selectedFiles?.length > 0) {
await fileUploadRef.value.uploadFiles()
}
const params: TbWorkcaseProcessDTO = {
workcaseId: props.workcaseId,
action: props.action,
processor: form.value.processor,
message: form.value.message || (props.action === 'assign' ? '工单指派' : '工单转派'),
files: form.value.fileIds.length > 0 ? form.value.fileIds : undefined
}
const res = await workcaseAPI.createWorkcaseProcess(params)
if (res.success) {
ElMessage.success(props.action === 'assign' ? '指派成功' : '转派成功')
visible.value = false
emit('success')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('指派/转派失败:', error)
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.assign-form {
.form-item {
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
.required {
color: #f56c6c;
}
}
}
}
</style>

View File

@@ -212,6 +212,7 @@ console.log('[配置] 当前配置', config);
// 单独导出常用配置项
export const API_BASE_URL = config.api.baseUrl;
export const FILE_DOWNLOAD_URL = config.file.downloadUrl;
export const FILE_UPLOAD_URL = config.file.uploadUrl;
export const PUBLIC_IMG_PATH = config.publicImgPath;
export const PUBLIC_WEB_PATH = config.publicWebPath;

View File

@@ -61,8 +61,6 @@ declare module 'shared/api' {
export const api: ApiInstance
export const TokenManager: any
export const authAPI: any
export const fileAPI: any
}
declare module 'shared/api/auth' {

View File

@@ -158,6 +158,15 @@
@cancel="showDetailDialog = false"
/>
</el-dialog>
<!-- 指派工单弹窗 -->
<WorkcaseAssign
v-model="showAssignDialog"
:workcase-id="assignWorkcaseId"
:current-processor="assignCurrentProcessor"
action="assign"
@success="handleAssignSuccess"
/>
</AdminLayout>
</template>
@@ -168,6 +177,7 @@ import { Plus, Search } from 'lucide-vue-next'
import { ElMessage, ElMessageBox } from 'element-plus'
import { workcaseAPI } from '@/api/workcase'
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
import { WorkcaseAssign } from '@/components'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase'
import type { PageRequest, PageParam } from 'shared/types'
@@ -183,6 +193,11 @@ const showDetailDialog = ref(false)
const currentWorkcase = ref<TbWorkcaseDTO>({})
const loading = ref(false)
// 指派弹窗相关
const showAssignDialog = ref(false)
const assignWorkcaseId = ref('')
const assignCurrentProcessor = ref('')
const formData = ref<TbWorkcaseDTO>({
username: '',
phone: '',
@@ -254,30 +269,19 @@ const createWorkcaseAPI = async () => {
}
/**
* 指派工单 - API调用
* 指派工单 - 打开指派弹窗
*/
const assignWorkcaseAPI = async (workcase: TbWorkcaseDTO) => {
const { value: processor } = await ElMessageBox.prompt('请输入处理人ID', '指派工单', {
confirmButtonText: '确定',
cancelButtonText: '取消'
}).catch(() => ({ value: '' }))
if (!processor) return
const process: TbWorkcaseProcessDTO = {
workcaseId: workcase.workcaseId,
action: 'assign',
processor: processor,
message: '工单指派'
}
const res = await workcaseAPI.createWorkcaseProcess(process)
if (res.success) {
ElMessage.success('指派成功')
loadWorkcases()
} else {
ElMessage.error(res.message || '指派失败')
}
assignWorkcaseId.value = workcase.workcaseId || ''
assignCurrentProcessor.value = workcase.processor || ''
showAssignDialog.value = true
}
/**
* 指派成功回调
*/
const handleAssignSuccess = () => {
loadWorkcases()
}
/**

View File

@@ -170,11 +170,17 @@
.section-title {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
margin-bottom: 16px;
color: #111827;
font-weight: 700;
font-size: 16px;
.title-left {
display: flex;
align-items: center;
gap: 8px;
}
}
.title-icon {
@@ -303,6 +309,52 @@
margin-bottom: 4px;
}
.timeline-files {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e5e7eb;
.files-label {
font-size: 12px;
color: #9ca3af;
margin-bottom: 4px;
}
.files-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #4b87ff;
text-decoration: none;
transition: background 0.2s;
&:hover {
background: #e5e7eb;
}
.file-icon {
font-size: 14px;
}
.file-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.timeline-time {
font-size: 12px;
color: #9ca3af;

View File

@@ -145,8 +145,14 @@
<!-- 处理记录 (仅查看模式) -->
<div class="timeline-section" v-if="mode === 'view' && processList.length > 0">
<div class="section-title">
<div class="title-bar"></div>
处理记录
<div class="title-left">
<div class="title-bar"></div>
处理记录
</div>
<!-- 工单处理人记录工单过程-->
<ElButton v-if="isProcessor" type="primary" size="small" @click="showAddProcessDialog = true">
添加处理记录
</ElButton>
</div>
<div class="timeline-content">
<div v-for="(item, index) in processList" :key="index" class="timeline-item">
@@ -161,6 +167,22 @@
<span class="timeline-action">{{ getActionText(item.action) }}:</span>
<span class="timeline-desc">{{ item.message }}</span>
</div>
<!-- 附件列表 -->
<div v-if="item.files && item.files.length > 0" class="timeline-files">
<div class="files-label">附件</div>
<div class="files-list">
<a
v-for="fileId in item.files"
:key="fileId"
:href="getFileDownloadUrl(fileId)"
target="_blank"
class="file-link"
>
<span class="file-icon">{{ getFileIcon(fileId) }}</span>
<span class="file-name">{{ getFileName(fileId) }}</span>
</a>
</div>
</div>
</div>
</div>
</div>
@@ -171,25 +193,100 @@
<footer class="detail-footer">
<ElButton @click="handleCancel">{{ mode === 'view' ? '关闭' : '取消' }}</ElButton>
<ElButton v-if="mode === 'create'" type="primary" @click="handleSubmit">创建工单</ElButton>
<ElButton v-if="mode === 'edit'" type="primary" @click="handleSubmit">保存修改</ElButton>
<ElButton v-if="mode === 'view' && formData.status === 'pending'" type="warning" @click="handleAssign">指派工程师</ElButton>
<ElButton v-if="mode === 'view' && formData.status === 'processing'" type="success" @click="handleComplete">完成工单</ElButton>
<!-- 来客视角只能撤销自己创建的工单 -->
<ElButton
v-if="mode === 'view' && isCreator && formData.status !== 'done'"
type="danger"
>
撤销工单
</ElButton>
<!-- 员工视角可以指派转派完成工单 -->
<ElButton
v-if="mode === 'view' && !isCreator && formData.status === 'pending'"
type="warning"
@click="handleAssign"
>
指派工程师
</ElButton>
<ElButton
v-if="mode === 'view' && !isCreator && formData.status === 'processing'"
type="warning"
@click="handleRedeploy"
>
转派工程师
</ElButton>
<ElButton
v-if="mode === 'view' && !isCreator && formData.status === 'processing'"
type="success"
@click="handleComplete"
>
完成工单
</ElButton>
</footer>
<!-- ChatMessage Dialog -->
<ElDialog v-model="showChatMessage" title="对话详情" width="800px" class="chat-dialog">
<ChatMessage v-if="showChatMessage" :room-id="currentRoomId" />
</ElDialog>
<!-- Add Process Record Dialog -->
<ElDialog v-model="showAddProcessDialog" title="添加处理记录" width="600px">
<div class="add-process-form">
<div class="form-item">
<label class="form-label">处理内容</label>
<ElInput
v-model="processForm.message"
type="textarea"
:rows="4"
placeholder="请详细描述处理过程、发现的问题或解决方案..."
maxlength="500"
show-word-limit
/>
</div>
<div class="form-item" style="margin-top: 16px;">
<label class="form-label">附件可选</label>
<FileUpload
ref="processFileUploadRef"
mode="content"
:max-count="5"
:max-size="10 * 1024 * 1024"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
v-model:file-list="processUploadedFiles"
@upload-success="handleProcessUploadSuccess"
@upload-error="handleProcessUploadError"
/>
</div>
</div>
<template #footer>
<ElButton @click="handleCloseProcessDialog">取消</ElButton>
<ElButton type="primary" @click="submitProcessRecord" :loading="submittingProcess">提交</ElButton>
</template>
</ElDialog>
<!-- Assign/Redeploy Dialog -->
<WorkcaseAssign
v-model="showAssignDialog"
:workcase-id="formData.workcaseId || ''"
:current-processor="formData.processor || ''"
:action="assignAction"
@success="handleAssignSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
import { ChatMessage } from '@/views/public/ChatRoom/'
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage, ElLoading } from 'element-plus'
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase/workcase'
import type { TbSysFileDTO } from 'shared/types'
import { workcaseAPI } from '@/api/workcase'
import { fileAPI } from 'shared/api/file'
import { FileUpload } from 'shared/components'
import { WorkcaseAssign } from '@/components'
import { FILE_DOWNLOAD_URL } from '@/config'
interface Props {
@@ -226,6 +323,35 @@ const showChatMessage = ref(false)
const currentRoomId = ref<string>('')
const processList = ref<TbWorkcaseProcessDTO[]>([])
// 文件信息缓存 (fileId -> TbSysFileDTO)
const fileInfoCache = ref<Map<string, TbSysFileDTO>>(new Map())
// 当前登录用户ID
const userId = ref<string>('')
// 判断是否是处理人
const isProcessor = computed(() => {
return formData.value.processor === userId.value
})
// 判断是否是创建人
const isCreator = computed(() => {
return formData.value.creator === userId.value
})
// 添加处理记录相关
const showAddProcessDialog = ref(false)
const submittingProcess = ref(false)
const processForm = ref({
message: '',
fileIds: [] as string[]
})
// 已上传的文件列表(用于显示)
const processUploadedFiles = ref<TbSysFileDTO[]>([])
// 指派/转派相关
const showAssignDialog = ref(false)
const assignAction = ref<'assign' | 'redeploy'>('assign')
// 加载工单详情
const loadWorkcaseDetail = async (workcaseId: string) => {
if (!workcaseId) return
@@ -255,12 +381,54 @@ const loadProcessList = async (workcaseId: string) => {
const res = await workcaseAPI.getWorkcaseProcessList({ workcaseId })
if (res.success && res.dataList) {
processList.value = res.dataList
// 加载所有文件信息
await loadFilesInfo(res.dataList)
}
} catch (error) {
console.error('加载处理记录失败:', error)
}
}
// 加载处理记录中的文件信息
const loadFilesInfo = async (processes: TbWorkcaseProcessDTO[]) => {
// 收集所有文件ID
const fileIds: string[] = []
processes.forEach(p => {
if (p.files) {
// 处理 files 可能是字符串或数组的情况
const filesArray = Array.isArray(p.files) ? p.files : [p.files]
filesArray.forEach(id => {
if (id && !fileInfoCache.value.has(id)) {
fileIds.push(id)
}
})
}
})
console.log('需要查询的文件ID:', fileIds)
if (fileIds.length === 0) return
// 逐个查询文件信息
for (const fileId of fileIds) {
try {
console.log('查询文件信息:', fileId)
const res = await fileAPI.getFileById(fileId)
console.log('文件信息结果:', res)
if (res.success && res.data) {
fileInfoCache.value.set(fileId, res.data)
}
} catch (error) {
console.error(`加载文件信息失败: ${fileId}`, error)
}
}
}
// 获取文件信息
const getFileInfo = (fileId: string): TbSysFileDTO | undefined => {
return fileInfoCache.value.get(fileId)
}
// 获取时间线圆点样式类
const getTimelineDotClass = (action?: string): string => {
switch (action) {
@@ -312,8 +480,50 @@ function getImageUrl(fileId: string): string {
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 获取文件下载URL
function getFileDownloadUrl(fileId: string): string {
if (!fileId) return ''
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 获取文件名
function getFileName(fileId: string): string {
const fileInfo = fileInfoCache.value.get(fileId)
return fileInfo?.name || fileId.substring(0, 8) + '...'
}
// 获取文件图标
function getFileIcon(fileId: string): string {
const fileInfo = fileInfoCache.value.get(fileId)
if (!fileInfo) return '📎'
const ext = (fileInfo.extension || fileInfo.name?.split('.').pop() || '').toLowerCase()
const iconMap: Record<string, string> = {
jpg: '🖼️',
jpeg: '🖼️',
png: '🖼️',
gif: '🖼️',
webp: '🖼️',
pdf: '📄',
doc: '📝',
docx: '📝',
xls: '📊',
xlsx: '📊',
ppt: '📽️',
pptx: '📽️',
zip: '📦',
rar: '📦',
txt: '📃'
}
return iconMap[ext] || '📎'
}
// 初始化
onMounted(() => {
// 获取当前登录用户ID
const loginDomain = JSON.parse(localStorage.getItem('loginDomain') || '{}')
userId.value = loginDomain?.userInfo?.userId || ''
if (props.mode === 'view' || props.mode === 'edit') {
// 查看/编辑模式:通过 workcaseId 加载数据
if (props.workcaseId) {
@@ -328,12 +538,11 @@ onMounted(() => {
}
} else if (props.mode === 'create') {
// 创建模式:初始化空表单,设置 roomId
const loginDomain = JSON.parse(localStorage.getItem('loginDomain') || '{}')
formData.value = {
roomId: props.roomId,
username: loginDomain?.userInfo?.username || '',
phone: loginDomain?.user?.phone || '',
userId: loginDomain?.user?.userId || '',
userId: userId.value,
emergency: 'normal',
imgs: []
}
@@ -455,12 +664,100 @@ const handleSubmit = async () => {
}
const handleAssign = () => {
emit('assign', formData.value.workcaseId!)
assignAction.value = 'assign'
showAssignDialog.value = true
}
const handleComplete = () => {
emit('complete', formData.value.workcaseId!)
}
// 处理转派
const handleRedeploy = () => {
assignAction.value = 'redeploy'
showAssignDialog.value = true
}
// 指派/转派成功回调
const handleAssignSuccess = async () => {
// 重新加载工单详情
if (formData.value.workcaseId) {
await loadWorkcaseDetail(formData.value.workcaseId)
}
// 触发事件通知父组件
if (assignAction.value === 'assign') {
emit('assign', formData.value.workcaseId!)
}
}
// 处理记录文件上传组件引用
const processFileUploadRef = ref<InstanceType<typeof FileUpload>>()
// 处理记录上传成功回调
const handleProcessUploadSuccess = (files: TbSysFileDTO[]) => {
console.log('文件上传成功:', files)
}
// 处理记录上传失败回调
const handleProcessUploadError = (error: string) => {
ElMessage.error(error)
}
// 关闭处理记录弹窗
const handleCloseProcessDialog = () => {
showAddProcessDialog.value = false
processForm.value = { message: '', fileIds: [] }
processUploadedFiles.value = []
if (processFileUploadRef.value) {
processFileUploadRef.value.clearFiles()
}
}
// 提交处理记录
const submitProcessRecord = async () => {
if (!processForm.value.message.trim()) {
ElMessage.warning('请输入处理内容')
return
}
if (!formData.value.workcaseId) {
ElMessage.error('工单ID不存在')
return
}
submittingProcess.value = true
try {
// 从已上传文件列表获取文件ID
const fileIds = processUploadedFiles.value
.map(f => f.fileId)
.filter((id): id is string => !!id)
// 提交处理记录
const params: TbWorkcaseProcessDTO = {
workcaseId: formData.value.workcaseId,
action: 'info',
message: processForm.value.message,
files: fileIds.length > 0 ? fileIds : undefined
}
const res = await workcaseAPI.createWorkcaseProcess(params)
if (res.success) {
ElMessage.success('处理记录添加成功')
handleCloseProcessDialog()
// 重新加载处理记录
if (formData.value.workcaseId) {
await loadProcessList(formData.value.workcaseId)
}
} else {
ElMessage.error(res.message || '添加失败')
}
} catch (error) {
console.error('添加处理记录失败:', error)
ElMessage.error('添加处理记录失败')
} finally {
submittingProcess.value = false
}
}
</script>
<style scoped lang="scss">