聊天室创建工单

This commit is contained in:
2025-12-24 19:50:38 +08:00
parent a613eb4fa1
commit 41bc41cfcd
18 changed files with 519 additions and 108 deletions

View File

@@ -6,6 +6,8 @@ import type { BaseDTO } from 'shared/types'
export interface TbWorkcaseDTO extends BaseDTO {
/** 工单ID */
workcaseId?: string
/** 聊天室ID */
roomId: string
/** 来客ID */
userId?: string
/** 来客姓名 */

View File

@@ -7,6 +7,7 @@
declare const uni: {
getStorageSync: (key: string) => any
request: (options: any) => void
uploadFile: (options: any) => void
}
import type { ResultDomain } from '../types'
@@ -46,4 +47,40 @@ export function request<T>(options: {
})
}
// 文件上传方法
export function uploadFile<T>(options: {
url: string
filePath: string
name?: string
formData?: Record<string, any>
header?: Record<string, string>
}): Promise<ResultDomain<T>> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
uni.uploadFile({
url: BASE_URL + options.url,
filePath: options.filePath,
name: options.name || 'file',
formData: options.formData,
header: {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.header
},
success: (res: any) => {
try {
if (res.statusCode === 200) {
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
resolve(result as ResultDomain<T>)
} else {
reject(new Error(`上传失败: ${res.statusCode}`))
}
} catch (error) {
reject(new Error('解析响应失败'))
}
},
fail: (err: any) => {
reject(err)
}
})
})
}

View File

@@ -0,0 +1,74 @@
import { request, uploadFile } from '../base'
import type { ResultDomain, TbSysFileDTO, FileUploadParam, BatchFileUploadParam } from '../../types'
import { BASE_URL } from '../../config'
export const fileAPI = {
baseUrl: '/urban-lifeline/file',
/**
* 上传单个文件uni-app 版本)
* @param filePath 文件临时路径
* @param param 文件上传参数
* @returns Promise<ResultDomain<TbSysFileDTO>>
*/
uploadFile(filePath: string, param?: FileUploadParam): Promise<ResultDomain<TbSysFileDTO>> {
return uploadFile<TbSysFileDTO>({
url: `${this.baseUrl}/upload`,
filePath: filePath,
name: 'file',
formData: {
module: param?.module || '',
optsn: param?.optsn || '',
uploader: param?.uploader || ''
}
})
},
/**
* 批量上传文件uni-app 版本)
* @param filePaths 文件临时路径数组
* @param param 批量文件上传参数
* @returns Promise<ResultDomain<TbSysFileDTO>>
*/
async batchUploadFiles(filePaths: string[], param?: BatchFileUploadParam): Promise<ResultDomain<TbSysFileDTO>> {
const uploadPromises = filePaths.map(filePath =>
this.uploadFile(filePath, param)
)
try {
const results = await Promise.all(uploadPromises)
// 返回包含所有上传文件的结果
const uploadedFiles = results
.filter((r: ResultDomain<TbSysFileDTO>) => r.success && r.data)
.map((r: ResultDomain<TbSysFileDTO>) => r.data!)
return {
success: true,
dataList: uploadedFiles
} as ResultDomain<TbSysFileDTO>
} catch (error) {
return {
success: false,
message: '批量上传失败'
} as ResultDomain<TbSysFileDTO>
}
},
/**
* 获取文件信息
* @param fileId 文件ID
* @returns Promise<ResultDomain<TbSysFileDTO>>
*/
getFileById(fileId: string): Promise<ResultDomain<TbSysFileDTO>> {
return request<TbSysFileDTO>({ url: `${BASE_URL}${this.baseUrl}/${fileId}`, method: 'GET' })
},
/**
* 获取文件下载 URL
* @param fileId 文件ID
* @returns string
*/
getDownloadUrl(fileId: string): string {
return `${BASE_URL}${this.baseUrl}/download/${fileId}`
}
}

View File

@@ -0,0 +1 @@
export * from './file'

View File

@@ -1,4 +1,5 @@
export * from "./base"
export * from "./sys"
export * from "./workcase"
export * from "./ai"
export * from "./ai"
export * from "./file"

View File

@@ -106,6 +106,7 @@
<script setup lang="ts">
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase'
import { wsClient } from '@/utils/websocket'
@@ -232,6 +233,11 @@ onMounted(() => {
initWebSocket()
})
// 页面显示时重新查询聊天室信息(从工单页返回时会自动刷新)
onShow(() => {
refreshChatRoomInfo()
})
// 组件卸载时断开WebSocket
onUnmounted(() => {
disconnectWebSocket()
@@ -253,6 +259,21 @@ watch(roomId, (newRoomId, oldRoomId) => {
const PAGE_SIZE = 20
const messageTotal = ref<number>(0)
// 刷新聊天室信息(仅更新 workcaseId 等基本信息,不重新加载消息)
async function refreshChatRoomInfo() {
if (!roomId.value) return
try {
const roomRes = await workcaseChatAPI.getChatRoomById(roomId.value)
if (roomRes.success && roomRes.data) {
roomName.value = roomRes.data.roomName || '聊天室'
workcaseId.value = roomRes.data.workcaseId || ''
messageTotal.value = roomRes.data.messageCount || 0
}
} catch (e) {
console.error('刷新聊天室信息失败:', e)
}
}
async function loadChatRoom() {
if (!roomId.value) return
loading.value = true

View File

@@ -224,7 +224,45 @@
margin-left: 16rpx;
}
// 铭牌照片
// 铭牌照片上传
.nameplate-upload {
width: 100%;
}
.nameplate-preview {
position: relative;
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
overflow: hidden;
border: 1rpx solid #e5e7eb;
}
.nameplate-placeholder {
width: 200rpx;
height: 200rpx;
border: 2rpx dashed #d1d5db;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f9fafb;
}
.nameplate-delete {
position: absolute;
top: 4rpx;
right: 4rpx;
width: 44rpx;
height: 44rpx;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.nameplate-photo {
width: 100%;
max-width: 400rpx;
@@ -240,6 +278,18 @@
height: 100%;
}
.delete-icon {
font-size: 32rpx;
color: #fff;
line-height: 1;
font-weight: 300;
}
.required {
color: #ef4444;
margin-left: 4rpx;
}
.status-tag {
padding: 4rpx 12rpx;
border-radius: 6rpx;

View File

@@ -37,28 +37,28 @@
<view class="form-container">
<!-- 客户姓名 -->
<view class="form-item">
<text class="form-label">客户姓名</text>
<text class="form-label">客户姓名<text class="required" v-if="mode === 'create'">*</text></text>
<input v-if="mode === 'create'" class="form-input" v-model="workcase.username" placeholder="请输入客户姓名" />
<text v-else class="form-value">{{ workcase.username || '-' }}</text>
</view>
<!-- 联系电话 -->
<view class="form-item">
<text class="form-label">联系电话</text>
<text class="form-label">联系电话<text class="required" v-if="mode === 'create'">*</text></text>
<input v-if="mode === 'create'" class="form-input" v-model="workcase.phone" placeholder="请输入联系电话" />
<text v-else class="form-value">{{ workcase.phone || '-' }}</text>
</view>
<!-- 设备名称 -->
<view class="form-item">
<text class="form-label">设备名称</text>
<text class="form-label">设备名称<text class="required" v-if="mode === 'create'">*</text></text>
<input v-if="mode === 'create'" class="form-input" v-model="workcase.device" placeholder="请输入设备名称" />
<text v-else class="form-value">{{ workcase.device || '-' }}</text>
</view>
<!-- 故障类型 -->
<view class="form-item">
<text class="form-label">故障类型</text>
<text class="form-label">故障类型<text class="required" v-if="mode === 'create'">*</text></text>
<picker v-if="mode === 'create'" class="form-picker" :value="typeIndex" :range="faultTypes" @change="onTypeChange">
<view class="picker-content">
<text class="picker-text">{{ workcase.type || '请选择故障类型' }}</text>
@@ -81,30 +81,46 @@
<!-- 现场地址 -->
<view class="form-item">
<text class="form-label">现场地址</text>
<text class="form-label">现场地址<text class="required" v-if="mode === 'create'">*</text></text>
<input v-if="mode === 'create'" class="form-input" v-model="workcase.address" placeholder="请输入现场地址" />
<text v-else class="form-value">{{ workcase.address || '-' }}</text>
</view>
<!-- 故障描述 -->
<view class="form-item">
<text class="form-label">故障描述</text>
<text class="form-label">故障描述<text class="required" v-if="mode === 'create'">*</text></text>
<textarea v-if="mode === 'create'" class="form-textarea" v-model="workcase.description" placeholder="请详细描述故障现象..." maxlength="500" />
<text v-else class="form-value">{{ workcase.description || '-' }}</text>
</view>
<!-- 设备铭牌 -->
<view class="form-item" v-if="mode !== 'create'">
<view class="form-item">
<text class="form-label">设备铭牌</text>
<text class="form-value">{{ workcase.deviceNamePlate || '-' }}</text>
<input v-if="mode === 'create'" class="form-input" v-model="workcase.deviceNamePlate" placeholder="请输入设备铭牌"/>
<text v-else class="form-value">{{ workcase.deviceNamePlate || '-' }}</text>
</view>
<!-- 铭牌照片 -->
<view class="form-item" v-if="mode !== 'create' && workcase.deviceNamePlateImg">
<text class="form-label">铭牌照片</text>
<view class="nameplate-photo" @tap="previewNameplateImage">
<image class="nameplate-image" :src="workcase.deviceNamePlateImg" mode="aspectFit" />
<view class="form-item">
<text class="form-label">铭牌照片<text class="required" v-if="mode === 'create'">*</text></text>
<!-- 创建模式:上传铭牌 -->
<view v-if="mode === 'create'" class="nameplate-upload">
<view v-if="workcase.deviceNamePlateImg" class="nameplate-preview" @tap="previewNameplateImage">
<image class="nameplate-image" :src="getImageUrl(workcase.deviceNamePlateImg)" mode="aspectFit" />
<view class="nameplate-delete" @tap.stop="deleteNameplateImage">
<text class="delete-icon">×</text>
</view>
</view>
<view v-else class="nameplate-placeholder" @tap="chooseNameplateImage">
<text class="upload-plus">+</text>
<text class="upload-text">上传设备铭牌</text>
</view>
</view>
<!-- 查看模式:显示铭牌 -->
<view v-else-if="workcase.deviceNamePlateImg" class="nameplate-photo" @tap="previewNameplateImage">
<image class="nameplate-image" :src="getImageUrl(workcase.deviceNamePlateImg)" mode="aspectFit" />
</view>
<text v-else class="form-value">-</text>
</view>
<!-- 处理人 -->
@@ -125,11 +141,11 @@
<view class="section">
<view class="section-title">
<view class="title-icon">📷</view>
<text class="title-text">故障图片</text>
<text class="title-text">故障图片<text class="required" v-if="mode === 'create'">*</text></text>
</view>
<view class="photos-grid">
<view class="photo-item" v-for="(img, index) in workcase.imgs" :key="index" @tap="previewFaultImages(index)">
<image class="photo-image" :src="img" mode="aspectFill" />
<image class="photo-image" :src="getImageUrl(img)" mode="aspectFill" />
<view v-if="mode === 'create'" class="photo-delete" @tap.stop="deleteFaultImage(index)">
<text class="delete-icon">×</text>
</view>
@@ -192,7 +208,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { TbWorkcaseDTO } from '@/types/workcase'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase'
import { workcaseAPI, fileAPI } from '@/api'
// 接口定义
interface ProcessItem {
@@ -216,49 +233,20 @@ const emergencies = ref<string[]>(['普通', '紧急'])
const emergencyIndex = ref<number>(0)
// 工单数据
const workcase = ref<TbWorkcaseDTO>({
workcaseId: 'W0202501130001',
userId: '1',
username: '张伟',
phone: '138****5678',
type: '电气系统故障',
device: 'TH-500GF',
deviceCode: 'TH-500GF-2023-001',
deviceNamePlate: 'TH-500GF',
deviceNamePlateImg: 'https://via.placeholder.com/400x300?text=设备铭牌',
address: '江西省南昌市红谷滩区xxx路xxx号',
description: '发电机启动后电压不稳定,波动范围较大,影响正常使用',
emergency: 'emergency',
status: 'processing',
processorName: '小李',
createTime: '2025-01-13 09:30:00',
imgs: []
})
const workcase = ref<TbWorkcaseDTO>({})
// 处理记录
const processList = ref<ProcessItem[]>([
{
status: 'engineer',
actor: '小李',
action: '开始处理',
desc: '已联系客户,计划今日上门检修',
time: '2025-01-13 08:15:00'
},
{
status: 'manager',
actor: '管理员',
action: '指派工程师',
desc: '指派给小李工程师处理',
time: '2025-01-12 15:00:00'
},
{
status: 'system',
actor: '系统',
action: '工单创建',
desc: '客户通过小电对话提交',
time: '2025-01-12 14:20:00'
const processList = ref<ProcessItem[]>([])
// 获取图片 URL通过 fileId
function getImageUrl(fileId: string): string {
// 如果已经是完整 URL直接返回
if (fileId.startsWith('http://') || fileId.startsWith('https://')) {
return fileId
}
])
// 否则通过 fileId 构建下载 URL
return fileAPI.getDownloadUrl(fileId)
}
// 生命周期
onLoad((options: any) => {
@@ -273,6 +261,8 @@ onLoad((options: any) => {
let username = ''
let phone = ''
let userId = ''
let roomId = options.roomId || ''
if (loginDomainRaw) {
// 如果是字符串,需要先解析
@@ -282,19 +272,17 @@ onLoad((options: any) => {
}
// 尝试多种可能的字段路径
username = loginDomain.userInfo?.username ||
loginDomain.user?.username ||
loginDomain.username || ''
username = loginDomain.userInfo?.username
phone = loginDomain.user?.phone ||
loginDomain.userInfo?.phone ||
loginDomain.phone || ''
phone = loginDomain.user?.phone
userId = loginDomain.userInfo?.userId
}
// 创建模式,初始化表单并填充用户信息
workcase.value = {
username: username,
phone: phone,
userId: userId,
device: '',
type: '',
address: '',
@@ -302,6 +290,11 @@ onLoad((options: any) => {
emergency: 'normal',
imgs: []
}
// 如果有 roomId添加到工单数据中
if (roomId) {
workcase.value.roomId = roomId
}
} catch (error) {
console.error('读取用户信息失败:', error)
// 初始化空表单
@@ -315,17 +308,18 @@ onLoad((options: any) => {
emergency: 'normal',
imgs: []
}
// 如果有 roomId添加到工单数据中
if (options.roomId) {
workcase.value.roomId = options.roomId
}
}
} else if (options.workcaseId) {
// 查看模式
workcaseId.value = options.workcaseId
mode.value = 'view'
loadWorkcaseDetail(workcaseId.value)
}
// 处理 roomId 参数
if (options.roomId) {
// TODO: 可以将 roomId 关联到工单
}
})
onMounted(() => {
@@ -349,8 +343,42 @@ onMounted(() => {
})
// 加载工单详情
function loadWorkcaseDetail(id: string) {
// TODO: 调用 workcaseAPI.getWorkcaseById(id) 获取数据
async function loadWorkcaseDetail(id: string) {
uni.showLoading({ title: '加载中...' })
try {
const res = await workcaseAPI.getWorkcaseById(id)
if (res.success && res.data) {
workcase.value = res.data
// 加载处理记录
await loadProcessList(id)
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' })
}
} catch (error) {
console.error('加载工单详情失败:', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 加载处理记录
async function loadProcessList(id: string) {
try {
const res = await workcaseAPI.getWorkcaseProcessList({ workcaseId: id })
if (res.success && res.dataList) {
// 转换为界面需要的格式
processList.value = res.dataList.map((item: TbWorkcaseProcessDTO) => ({
status: item.processorType === 'system' ? 'system' : item.processorType === 'manager' ? 'manager' : 'engineer',
actor: item.processorName || '未知',
action: item.action || '',
desc: item.description || '',
time: item.createTime || ''
}))
}
} catch (error) {
console.error('加载处理记录失败:', error)
}
}
// 获取状态样式类
@@ -428,16 +456,43 @@ function onEmergencyChange(e: any) {
}
// 选择故障图片
function chooseFaultImages() {
async function chooseFaultImages() {
uni.chooseImage({
count: 9 - (workcase.value.imgs?.length || 0),
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: (res) => {
success: async (res) => {
if (!workcase.value.imgs) {
workcase.value.imgs = []
}
workcase.value.imgs.push(...res.tempFilePaths)
// 上传图片到服务器
uni.showLoading({ title: '上传中...' })
try {
const uploadPromises = res.tempFilePaths.map(filePath =>
fileAPI.uploadFile(filePath, {
module: 'workcase',
optsn: workcase.value.workcaseId || 'temp'
})
)
const results = await Promise.all(uploadPromises)
// 使用 fileId 添加图片
results.forEach(result => {
if (result.success && result.data?.fileId) {
// 存储 fileId用于提交时使用
workcase.value.imgs!.push(result.data.fileId)
}
})
uni.hideLoading()
uni.showToast({ title: '上传成功', icon: 'success' })
} catch (error) {
uni.hideLoading()
console.error('上传图片失败:', error)
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
fail: (err) => {
console.log('选择图片失败:', err)
@@ -452,11 +507,52 @@ function deleteFaultImage(index: number) {
}
}
// 选择铭牌照片
async function chooseNameplateImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: async (res) => {
// 上传铭牌照片到服务器
uni.showLoading({ title: '上传中...' })
try {
const result = await fileAPI.uploadFile(res.tempFilePaths[0], {
module: 'workcase',
optsn: workcase.value.workcaseId || 'temp'
})
if (result.success && result.data?.fileId) {
workcase.value.deviceNamePlateImg = result.data.fileId
uni.hideLoading()
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
uni.hideLoading()
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (error) {
uni.hideLoading()
console.error('上传铭牌照片失败:', error)
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
fail: (err) => {
console.log('选择图片失败:', err)
}
})
}
// 删除铭牌照片
function deleteNameplateImage() {
workcase.value.deviceNamePlateImg = ''
}
// 预览铭牌照片
function previewNameplateImage() {
if (!workcase.value.deviceNamePlateImg) return
const imageUrl = getImageUrl(workcase.value.deviceNamePlateImg)
uni.previewImage({
urls: [workcase.value.deviceNamePlateImg],
urls: [imageUrl],
current: 0
})
}
@@ -464,38 +560,79 @@ function previewNameplateImage() {
// 预览故障图片
function previewFaultImages(index: number) {
if (!workcase.value.imgs || workcase.value.imgs.length === 0) return
// 将 fileId 数组转换为 URL 数组
const imageUrls = workcase.value.imgs.map(fileId => getImageUrl(fileId))
uni.previewImage({
urls: workcase.value.imgs,
urls: imageUrls,
current: index
})
}
// 提交工单
function submitWorkcase() {
async function submitWorkcase() {
// 校验必填项
if (!workcase.value.username || !workcase.value.phone || !workcase.value.description) {
uni.showToast({
title: '请填写必填项',
icon: 'none'
})
if (!workcase.value.username) {
uni.showToast({ title: '请输入客户姓名', icon: 'none' })
return
}
if (!workcase.value.phone) {
uni.showToast({ title: '请输入联系电话', icon: 'none' })
return
}
if (!workcase.value.device) {
uni.showToast({ title: '请输入设备名称', icon: 'none' })
return
}
if (!workcase.value.type) {
uni.showToast({ title: '请选择故障类型', icon: 'none' })
return
}
if (!workcase.value.address) {
uni.showToast({ title: '请输入现场地址', icon: 'none' })
return
}
if (!workcase.value.description) {
uni.showToast({ title: '请输入故障描述', icon: 'none' })
return
}
if (!workcase.value.deviceNamePlateImg) {
uni.showToast({ title: '请上传设备铭牌照片', icon: 'none' })
return
}
if (!workcase.value.imgs || workcase.value.imgs.length === 0) {
uni.showToast({ title: '请至少上传一张故障图片', icon: 'none' })
return
}
uni.showLoading({
title: '提交中...'
})
uni.showLoading({ title: '提交中...' })
// TODO: 调用 API 提交工单
setTimeout(() => {
try {
// 调用 API 提交工单
const res = await workcaseAPI.createWorkcase(workcase.value)
uni.hideLoading()
if (res.success && res.data) {
uni.showToast({
title: '工单创建成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.message || '创建失败',
icon: 'none'
})
}
} catch (error) {
uni.hideLoading()
console.error('提交工单失败:', error)
uni.showToast({
title: '工单创建成功',
icon: 'success'
title: '提交失败,请重试',
icon: 'none'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}, 1000)
}
}
// 返回上一页

View File

@@ -0,0 +1,40 @@
/**
* 文件服务相关 types - 根据后端 VO 和 DTO 转换
*/
import type { BaseDTO } from "../base"
// TbSysFileDTO - 系统文件DTO
export interface TbSysFileDTO extends BaseDTO {
/** 文件ID (主键) */
fileId?: string
/** 文件名 */
name?: string
/** 文件路径 */
path?: string
/** 文件大小(字节) */
size?: number
/** 文件类型 */
type?: string
/** 存储类型 */
storageType?: string
/** MIME 类型 */
mimeType?: string
/** 文件访问 URL */
url?: string
/** 文件状态 */
status?: string
}
// uni-app 文件上传参数(不使用 File 对象,使用文件路径)
export interface FileUploadParam {
module?: string
optsn?: string
uploader?: string
}
export interface BatchFileUploadParam {
module?: string
optsn?: string
uploader?: string
}

View File

@@ -0,0 +1 @@
export * from "./file"

View File

@@ -4,4 +4,5 @@ export * from "./workcase"
export * from "./auth"
export * from "./response"
export * from "./page"
export * from "./ai"
export * from "./ai"
export * from "./file"

View File

@@ -1,4 +1,4 @@
import type { BaseDTO } from '@/types/base'
import type { BaseDTO } from '../base'
/**
* 工单表对象
@@ -6,14 +6,16 @@ import type { BaseDTO } from '@/types/base'
export interface TbWorkcaseDTO extends BaseDTO {
/** 工单ID */
workcaseId?: string
/** 聊天室ID */
roomId?: string
/** 来客ID */
userId?: string
/** 来客姓名 */
username?: string
username: string
/** 来客电话 */
phone?: string
phone: string
/** 故障类型 */
type?: string
type: string
/** 设备名称 */
device?: string
/** 设备代码 */
@@ -21,7 +23,7 @@ export interface TbWorkcaseDTO extends BaseDTO {
/** 设备名称牌 */
deviceNamePlate?: string
/** 设备名称牌图片 */
deviceNamePlateImg?: string
deviceNamePlateImg: string
/** 地址 */
address?: string
/** 故障描述 */