1轮修复

This commit is contained in:
2026-01-20 16:17:39 +08:00
parent 0bf7361672
commit 8ab6107f25
23 changed files with 2587 additions and 612 deletions

View File

@@ -0,0 +1,64 @@
# ================================================
# Urban Lifeline - 通用 Bootstrap 配置
# 所有微服务共享的基础配置
# ================================================
# ================== Spring Cloud Nacos ==================
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: ${NACOS_GROUP:DEFAULT_GROUP}
# ================== DataSource ==================
datasource:
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/urban_lifeline}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
# ================== Redis ==================
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:123456}
# ================== Security AES ==================
security:
aes:
# AES-256 密钥Base64编码必须与所有服务保持一致
# 警告:这是开发环境密钥,生产环境请使用密钥管理系统
secret-key: ${AES_SECRET_KEY:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=}
# ================== Dubbo ==================
dubbo:
protocol:
name: dubbo
port: -1
registry:
address: nacos://${NACOS_SERVER_ADDR:127.0.0.1:8848}
# ================== MyBatis-Plus ==================
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: org.xyzh.common.dto, org.xyzh.api
# ================== SpringDoc 基础配置 ==================
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
# ================== Logging ==================
logging:
config: classpath:log4j2.xml
charset:
console: UTF-8
file: UTF-8

View File

@@ -0,0 +1,64 @@
# ================================================
# Urban Lifeline - 通用 Bootstrap 配置
# 所有微服务共享的基础配置
# ================================================
# ================== Spring Cloud Nacos ==================
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: ${NACOS_GROUP:DEFAULT_GROUP}
# ================== DataSource ==================
datasource:
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/urban_lifeline}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
# ================== Redis ==================
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:123456}
# ================== Security AES ==================
security:
aes:
# AES-256 密钥Base64编码必须与所有服务保持一致
# 警告:这是开发环境密钥,生产环境请使用密钥管理系统
secret-key: ${AES_SECRET_KEY:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=}
# ================== Dubbo ==================
dubbo:
protocol:
name: dubbo
port: -1
registry:
address: nacos://${NACOS_SERVER_ADDR:127.0.0.1:8848}
# ================== MyBatis-Plus ==================
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: org.xyzh.common.dto, org.xyzh.api
# ================== SpringDoc 基础配置 ==================
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
# ================== Logging ==================
logging:
config: classpath:log4j2.xml
charset:
console: UTF-8
file: UTF-8

View File

@@ -0,0 +1,64 @@
# ================================================
# Urban Lifeline - 通用 Bootstrap 配置
# 所有微服务共享的基础配置
# ================================================
# ================== Spring Cloud Nacos ==================
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: ${NACOS_GROUP:DEFAULT_GROUP}
# ================== DataSource ==================
datasource:
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/urban_lifeline}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
# ================== Redis ==================
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:123456}
# ================== Security AES ==================
security:
aes:
# AES-256 密钥Base64编码必须与所有服务保持一致
# 警告:这是开发环境密钥,生产环境请使用密钥管理系统
secret-key: ${AES_SECRET_KEY:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=}
# ================== Dubbo ==================
dubbo:
protocol:
name: dubbo
port: -1
registry:
address: nacos://${NACOS_SERVER_ADDR:127.0.0.1:8848}
# ================== MyBatis-Plus ==================
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: org.xyzh.common.dto, org.xyzh.api
# ================== SpringDoc 基础配置 ==================
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
# ================== Logging ==================
logging:
config: classpath:log4j2.xml
charset:
console: UTF-8
file: UTF-8

View File

@@ -10,6 +10,7 @@ import com.alibaba.fastjson2.JSON;
import org.apache.dubbo.config.annotation.DubboReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -81,7 +82,7 @@ public class GuestController {
@PostMapping
public ResultDomain<TbGuestDTO> createGuest(TbGuestDTO guest) {
public ResultDomain<TbGuestDTO> createGuest(@RequestBody TbGuestDTO guest) {
ValidationUtils.validate(guest, Arrays.asList(
ValidationUtils.requiredString("name", "姓名"),
ValidationUtils.atLeastOne(
@@ -93,7 +94,7 @@ public class GuestController {
}
@PutMapping
public ResultDomain<TbGuestDTO> updateGuest(TbGuestDTO guest) {
public ResultDomain<TbGuestDTO> updateGuest(@RequestBody TbGuestDTO guest) {
ValidationUtils.validate(guest, Arrays.asList(
ValidationUtils.requiredString("name", "姓名"),
ValidationUtils.atLeastOne(
@@ -104,8 +105,8 @@ public class GuestController {
return guestService.updateGuest(guest);
}
@DeleteMapping
public ResultDomain<TbGuestDTO> deleteGuest(@NotNull String userId) {
@DeleteMapping("/{userId}")
public ResultDomain<TbGuestDTO> deleteGuest(@PathVariable("userId") @NotNull String userId) {
return guestService.deleteGuest(userId);
}
@@ -115,13 +116,13 @@ public class GuestController {
}
@GetMapping("/list")
public ResultDomain<TbGuestDTO> listGuest(TbGuestDTO filter) {
@PostMapping("/list")
public ResultDomain<TbGuestDTO> listGuest(@RequestBody TbGuestDTO filter) {
return guestService.selectGuestList(filter);
}
@PostMapping("/page")
public ResultDomain<TbGuestDTO> pageGuest(PageRequest<TbGuestDTO> pageRequest) {
public ResultDomain<TbGuestDTO> pageGuest(@RequestBody PageRequest<TbGuestDTO> pageRequest) {
return guestService.selectGuestPage(pageRequest);
}

View File

@@ -3,6 +3,8 @@ package org.xyzh.system.mapper.user;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.security.access.method.P;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.dto.sys.TbGuestDTO;
@@ -64,7 +66,7 @@ public interface TbGuestMapper extends BaseMapper<TbGuestDTO>{
* @author yslg
* @since 2025-12-18
*/
List<TbGuestDTO> selectGuestList(TbGuestDTO guest);
List<TbGuestDTO> selectGuestList(@Param("filter") TbGuestDTO filter);
/**
* @description 查询来客分页列表
@@ -73,7 +75,7 @@ public interface TbGuestMapper extends BaseMapper<TbGuestDTO>{
* @author yslg
* @since 2025-12-18
*/
List<TbGuestDTO> selectGuestPage(TbGuestDTO guest, PageParam pageParam);
List<TbGuestDTO> selectGuestPage(@Param("filter") TbGuestDTO guest, @Param("pageParam") PageParam pageParam);
int countGuest(TbGuestDTO guest);
}

View File

@@ -0,0 +1,64 @@
# ================================================
# Urban Lifeline - 通用 Bootstrap 配置
# 所有微服务共享的基础配置
# ================================================
# ================== Spring Cloud Nacos ==================
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: ${NACOS_GROUP:DEFAULT_GROUP}
# ================== DataSource ==================
datasource:
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/urban_lifeline}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
# ================== Redis ==================
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:123456}
# ================== Security AES ==================
security:
aes:
# AES-256 密钥Base64编码必须与所有服务保持一致
# 警告:这是开发环境密钥,生产环境请使用密钥管理系统
secret-key: ${AES_SECRET_KEY:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=}
# ================== Dubbo ==================
dubbo:
protocol:
name: dubbo
port: -1
registry:
address: nacos://${NACOS_SERVER_ADDR:127.0.0.1:8848}
# ================== MyBatis-Plus ==================
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: org.xyzh.common.dto, org.xyzh.api
# ================== SpringDoc 基础配置 ==================
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
# ================== Logging ==================
logging:
config: classpath:log4j2.xml
charset:
console: UTF-8
file: UTF-8

View File

@@ -62,7 +62,15 @@ public class WorkcaseController {
if (!vr.isValid()) {
return ResultDomain.failure(vr.getAllErrors());
}
return workcaseService.createWorkcase(workcase);
try{
workcaseService.createWorkcase(workcase);
return ResultDomain.success("创建工单成功", workcase);
} catch (Exception e) {
if(e.getMessage().contains("tb_workcase_room_id_key")){
return ResultDomain.failure("您已有未完成的工单,请勿重复提交");
}
return ResultDomain.failure("创建工单失败: " + e.getMessage());
}
}
@Operation(summary = "更新工单")

View File

@@ -20,6 +20,7 @@ import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.xyzh.api.file.dto.TbSysFileDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
@@ -76,6 +77,7 @@ public class WorkcaseServiceImpl implements WorkcaseService {
// ====================== 工单管理 ======================
@Override
@Transactional
public ResultDomain<TbWorkcaseDTO> createWorkcase(TbWorkcaseDTO workcase) {
logger.info("创建工单: userId={}, type={}, roomId={}", workcase.getUserId(), workcase.getType(), workcase.getRoomId());

View File

@@ -0,0 +1,64 @@
# ================================================
# Urban Lifeline - 通用 Bootstrap 配置
# 所有微服务共享的基础配置
# ================================================
# ================== Spring Cloud Nacos ==================
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:dev}
group: ${NACOS_GROUP:DEFAULT_GROUP}
# ================== DataSource ==================
datasource:
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/urban_lifeline}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
# ================== Redis ==================
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:123456}
# ================== Security AES ==================
security:
aes:
# AES-256 密钥Base64编码必须与所有服务保持一致
# 警告:这是开发环境密钥,生产环境请使用密钥管理系统
secret-key: ${AES_SECRET_KEY:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=}
# ================== Dubbo ==================
dubbo:
protocol:
name: dubbo
port: -1
registry:
address: nacos://${NACOS_SERVER_ADDR:127.0.0.1:8848}
# ================== MyBatis-Plus ==================
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: org.xyzh.common.dto, org.xyzh.api
# ================== SpringDoc 基础配置 ==================
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
# ================== Logging ==================
logging:
config: classpath:log4j2.xml
charset:
console: UTF-8
file: UTF-8

View File

@@ -1847,12 +1847,6 @@
"resolved": "packages/workcase",
"link": true
},
"node_modules/@vant/weapp": {
"version": "1.11.7",
"resolved": "https://registry.npmmirror.com/@vant/weapp/-/weapp-1.11.7.tgz",
"integrity": "sha512-Rwn9BBnb4kHSV4XmvBicwtd42J+amEUfnFDcXJsGNPNX4a9c/DoT6YLsm4X1wB2+sQbdiQsbFBLAvGRBxCkD8g==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -5038,10 +5032,6 @@
"node": ">=16.0.0"
}
},
"node_modules/taihao-service-miniprogram": {
"resolved": "packages/wechat_demo",
"link": true
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5509,12 +5499,14 @@
"@vueuse/core": "^11.3.0",
"axios": "^1.7.9",
"element-plus": "^2.12.0",
"lucide-vue-next": "^0.561.0",
"pinia": "^2.2.8",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@module-federation/vite": "^1.9.3",
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"typescript": "^5.7.2",
@@ -5522,6 +5514,16 @@
"vue-tsc": "^2.2.0"
}
},
"packages/bidding/node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/platform": {
"name": "@urbanlifeline/platform",
"version": "1.0.0",
@@ -5551,6 +5553,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@stomp/stompjs": "^7.2.1",
"axios": "^1.7.0",
"cors": "^2.8.5",
"element-plus": "^2.12.0",
"express": "^4.18.2",
@@ -7031,6 +7034,7 @@
"packages/wechat_demo": {
"name": "taihao-service-miniprogram",
"version": "1.0.0",
"extraneous": true,
"dependencies": {
"@vant/weapp": "^1.11.7"
}

View File

@@ -47,7 +47,7 @@ export const guestAPI = {
* 获取来客列表
*/
async listGuest(filter?: TbGuestDTO): Promise<ResultDomain<TbGuestDTO>> {
const response = await api.get<TbGuestDTO>(`${this.baseUrl}/list`, { params: filter })
const response = await api.post<TbGuestDTO>(`${this.baseUrl}/list`, { params: filter })
return response.data
},

View File

@@ -50,6 +50,7 @@ export default defineConfig({
'./api/auth': './src/api/auth/auth.ts',
'./api/file': './src/api/file/file.ts',
'./api/ai': './src/api/ai/index.ts',
'./api/sys/guest': './src/api/sys/guest.ts',
// ========== Utils 工具模块 ==========
'./utils': './src/utils/index.ts',

View File

@@ -1 +1,5 @@
export * from './workcase'
export * from './workcase'
// 从shared导入系统和聊天相关API
export { guestAPI as systemAPI } from 'shared/api/sys/guest'
export { workcaseChatAPI as chatAPI } from './workcase'

View File

@@ -77,6 +77,10 @@ declare module 'shared/api/ai' {
export const aiChatAPI: any
}
declare module 'shared/api/sys/guest' {
export const guestAPI: any
}
// ============ types模块 ==================
declare module 'shared/types' {
// 基础类型
@@ -117,7 +121,7 @@ declare module 'shared/types' {
export type { LoginParam, LoginDomain } from '../../../shared/src/types/auth'
// 重新导出 sys
export type { SysUserVO, SysConfigVO, TbSysViewDTO } from '../../../shared/src/types/sys'
export type { SysUserVO, SysConfigVO, TbSysViewDTO, TbGuestDTO } from '../../../shared/src/types/sys'
// 重新导出 file
export type { TbSysFileDTO } from '../../../shared/src/types/file'

View File

@@ -1,11 +1,11 @@
<template>
<AdminLayout title="工单管理" info="查看和处理客户服务工单">
<!-- <template #action>
<template #action>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
创建工单
</el-button>
</template> -->
</template>
<div class="workcase-container">
<!-- 筛选区域 -->
@@ -97,18 +97,42 @@
<!-- 创建工单弹窗 -->
<el-dialog v-model="showCreateDialog" title="创建工单" width="650px">
<el-form :model="formData" label-width="90px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="客户姓名" required>
<el-input v-model="formData.username" placeholder="请输入客户姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话" required>
<el-input v-model="formData.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="来客人员" required>
<el-select
v-model="selectedGuestId"
placeholder="请选择来客人员"
style="width: 100%;"
:loading="loadingGuests"
filterable
clearable
>
<el-option
v-for="guest in guestsList"
:key="guest.userId"
:label="`${guest.name} (${guest.phone || '无电话'})`"
:value="guest.userId"
/>
</el-select>
</el-form-item>
<el-form-item label="聊天室" required>
<el-select
v-model="formData.roomId"
placeholder="请选择聊天室"
style="width: 100%;"
:loading="loadingChatRooms"
filterable
clearable
>
<el-option
v-for="room in chatRoomsList"
:key="room.roomId"
:label="`聊天室 ${room.roomName}`"
:value="room.roomId"
/>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="故障类型" required>
@@ -131,6 +155,13 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备代码" required>
<el-input v-model="formData.deviceCode" placeholder="请输入设备代码" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="设备型号" required>
<el-select v-model="formData.device" placeholder="请选择设备型号" style="width: 100%;">
<el-option label="TH-500GF" value="TH-500GF" />
@@ -172,15 +203,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Plus, Search } from 'lucide-vue-next'
import { ElMessage, ElMessageBox } from 'element-plus'
import { workcaseAPI } from '@/api/workcase'
import { workcaseAPI, chatAPI } from '@/api'
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'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, TbChatRoomDTO } from '@/types/workcase'
import type { PageRequest, PageParam, TbGuestDTO } from 'shared/types'
import { guestAPI } from 'shared/api/sys/guest'
const statusFilter = ref('all')
const typeFilter = ref('')
@@ -201,8 +233,12 @@ const assignCurrentProcessor = ref('')
const formData = ref<TbWorkcaseDTO>({
username: '',
userId: '',
phone: '',
device: '',
deviceCode: '',
roomId: '',
deviceNamePlate: '',
type: '',
emergency: 'normal',
description: ''
@@ -210,6 +246,97 @@ const formData = ref<TbWorkcaseDTO>({
const workcaseList = ref<TbWorkcaseDTO[]>([])
// 来客人员相关
const guestsList = ref<TbGuestDTO[]>([])
const loadingGuests = ref(false)
const selectedGuestId = ref('')
// 聊天室相关
const chatRoomsList = ref<TbChatRoomDTO[]>([])
const loadingChatRooms = ref(false)
// 加载来客人员列表
const loadGuests = async () => {
loadingGuests.value = true
try {
const res = await guestAPI.listGuest()
if (res.success) {
guestsList.value = res.dataList || []
} else {
ElMessage.error(res.message || '加载来客人员失败')
}
} catch (error) {
ElMessage.error('加载来客人员失败')
} finally {
loadingGuests.value = false
}
}
// 根据来客ID加载聊天室列表
const loadChatRoomsByGuest = async (guestId: string) => {
if (!guestId) {
chatRoomsList.value = []
return
}
loadingChatRooms.value = true
try {
const filter = {
guestId: guestId
}
const pageRequest = {
filter,
pageParam: {
page: 1,
pageSize: 100
}
}
const res = await chatAPI.getChatRoomPage(pageRequest)
if (res.success) {
chatRoomsList.value = res.dataList || res.pageDomain?.dataList || []
} else {
ElMessage.error(res.message || '加载聊天室列表失败')
}
} catch (error) {
ElMessage.error('加载聊天室列表失败')
} finally {
loadingChatRooms.value = false
}
}
// 监听来客人员选择变化,加载对应的聊天室列表
watch(selectedGuestId, (newGuestId) => {
const selectedGuest = guestsList.value.find(guest => guest.userId === newGuestId)
if (selectedGuest) {
formData.value.username = selectedGuest.name
formData.value.userId = selectedGuest.userId
formData.value.phone = selectedGuest.phone
// 加载该来客的聊天室列表
loadChatRoomsByGuest(selectedGuest.userId)
} else {
formData.value.username = ''
formData.value.userId = ''
formData.value.phone = ''
chatRoomsList.value = []
}
})
// 监听弹窗打开状态,加载来客人员列表
watch(showCreateDialog, (newVal) => {
if (newVal) {
// 弹窗打开时,加载来客人员列表
loadGuests()
} else {
// 弹窗关闭时,重置相关数据
selectedGuestId.value = ''
formData.value.username = ''
formData.value.userId = ''
formData.value.phone = ''
formData.value.roomId = ''
chatRoomsList.value = []
}
})
// ========================= API 调用方法 =========================
/**
* 加载工单列表
@@ -253,10 +380,11 @@ const loadWorkcases = async () => {
* 创建工单 - API调用
*/
const createWorkcaseAPI = async () => {
if (!formData.value.username || !formData.value.phone || !formData.value.device || !formData.value.type) {
if (!formData.value.username || !formData.value.phone || !formData.value.deviceCode || !formData.value.type || !formData.value.roomId) {
ElMessage.error('请填写必填项')
return
}
formData.value.deviceNamePlate = formData.value.deviceCode; // 将设备代码赋值给设备铭牌字段
const res = await workcaseAPI.createWorkcase(formData.value)
if (res.success) {

View File

@@ -21,5 +21,16 @@ export const guestAPI = {
url: `/urban-lifeline/system/guest/wechat/${wechatId}`,
method: 'GET'
})
},
/**
* 获取可选人员列表
*/
listGuest(filter?: TbGuestDTO): Promise<ResultDomain<TbGuestDTO>> {
return request<TbGuestDTO>({
url: '/urban-lifeline/system/guest/list',
method: 'POST',
data: filter||{}
})
}
}

View File

@@ -1,7 +1,10 @@
export const AGENT_ID = '17678420499370001'
// export const BASE_URL = 'http://localhost:8180'
export const BASE_URL = 'http://localhost:8180'
// 根据宝塔nginx配置/urban-lifeline/ 会代理到后端网关
export const BASE_URL = 'https://demo-urbanlifeline.tensorgrove.com/urban-lifeline'
// export const BASE_URL = 'https://demo-urbanlifeline.tensorgrove.com/urban-lifeline'
// export const WS_HOST = 'localhost:8180' // WebSocket host不包含协议
export const WS_HOST = 'demo-urbanlifeline.tensorgrove.com/urban-lifeline' // WebSocket host不包含协议
export const MEET_URL = 'https://org.xyzh.yslg/workcase'
// export const MEET_URL = 'https://demo-urbanlifeline.tensorgrove.com/workcase'
export const WS_HOST = 'localhost:8180' // WebSocket host不包含协议
// export const WS_HOST = 'demo-urbanlifeline.tensorgrove.com/urban-lifeline' // WebSocket host不包含协议

View File

@@ -97,6 +97,13 @@
<view class="message-content" v-else>
<view class="bubble other-bubble">
<rich-text :nodes="renderMarkdown(msg.content || '')" class="message-rich-text"></rich-text>
<!-- 文件列表 -->
<view v-if="msg.files && msg.files.length > 0" class="message-files">
<view v-for="fileId in msg.files" :key="fileId" class="message-file-item" @tap="downloadFile(fileId)">
<text class="file-icon">📎</text>
<text class="file-name">{{ getFileNameFromId(fileId) }}</text>
</view>
</view>
</view>
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view>
@@ -112,6 +119,13 @@
<view class="message-content" v-else>
<view class="bubble self-bubble">
<text class="message-text">{{ msg.content }}</text>
<!-- 文件列表 -->
<view v-if="msg.files && msg.files.length > 0" class="message-files">
<view v-for="fileId in msg.files" :key="fileId" class="message-file-item" @tap="downloadFile(fileId)">
<text class="file-icon">📎</text>
<text class="file-name">{{ getFileNameFromId(fileId) }}</text>
</view>
</view>
</view>
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view>
@@ -126,10 +140,29 @@
<!-- 底部输入区 -->
<view class="footer">
<!-- 文件预览区域 -->
<view v-if="selectedFiles.length > 0" class="file-preview-container">
<view v-for="(file, index) in selectedFiles" :key="index" class="file-preview-item">
<view class="file-info">
<text class="file-name">{{ file.name }}</text>
<text class="file-size">{{ formatFileSize(file.size) }}</text>
</view>
<view class="file-actions">
<text class="file-delete" @tap="removeFile(index)">删除</text>
</view>
</view>
</view>
<!-- 输入和操作行 -->
<view class="input-row">
<view class="input-actions">
<view class="action-btn" @tap="chooseFile">
<text class="action-icon">📎</text>
</view>
</view>
<input class="chat-input" v-model="inputText" placeholder="输入消息..."
@confirm="sendMessage" />
<view class="send-btn" @tap="sendMessage">
<view class="send-btn" @tap="sendMessage" :class="{ disabled: isUploading || selectedFiles.length === 0 && !inputText.trim() }">
<text class="send-icon">➤</text>
</view>
</view>
@@ -148,6 +181,7 @@ import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue'
import CommentMessageCard from './CommentMessageCard/CommentMessageCard.uvue'
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase'
import { fileAPI } from '@/api/file'
import { wsClient } from '@/utils/websocket'
import { WS_HOST } from '@/config'
// 响应式数据
@@ -167,6 +201,11 @@ const loadingMore = ref<boolean>(false)
const currentPage = ref<number>(1)
const hasMore = ref<boolean>(true)
// 文件上传相关变量
const uploadingFiles = ref<any[]>([])
const selectedFiles = ref<any[]>([])
const isUploading = ref<boolean>(false)
// 用户信息从storage获取
const currentUserId = ref<string>('')
const currentUserName = ref<string>('我')
@@ -516,7 +555,7 @@ function renderMarkdown(text: string): string {
// 发送消息
async function sendMessage() {
const text = inputText.value.trim()
if (!text || sending.value) return
if ((!text && selectedFiles.value.length === 0) || sending.value || isUploading.value) return
sending.value = true
const tempId = Date.now().toString()
@@ -530,13 +569,17 @@ async function sendMessage() {
senderName: currentUserName.value,
content: text,
sendTime: new Date().toISOString(),
status: 'sending'
status: 'sending',
files: []
}
messages.push(tempMsg)
inputText.value = ''
nextTick(() => scrollToBottom())
try {
// 上传文件
const fileIds = await uploadFiles()
// 调用API发送消息
const msgDTO: TbChatRoomMessageDTO = {
roomId: roomId.value,
@@ -544,7 +587,8 @@ async function sendMessage() {
senderType: 'guest',
senderName: currentUserName.value,
messageType: 'text',
content: text
content: text,
files: fileIds
}
const res = await workcaseChatAPI.sendMessage(msgDTO)
if (res.success && res.data) {
@@ -734,6 +778,140 @@ async function handleCommentSubmit(rating: number) {
}
}
// 文件操作相关函数
// 选择文件
async function chooseFile() {
try {
const res = await uni.chooseMessageFile({
count: 9, // 最多选择9个文件
type: 'file',
ext: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt', 'jpg', 'jpeg', 'png', 'gif']
})
if (res.tempFiles && res.tempFiles.length > 0) {
// 添加到选中文件列表
selectedFiles.value = [...selectedFiles.value, ...res.tempFiles]
}
} catch (error) {
console.error('选择文件失败:', error)
uni.showToast({ title: '选择文件失败', icon: 'none' })
}
}
// 删除选中的文件
function removeFile(index: number) {
selectedFiles.value.splice(index, 1)
}
// 格式化文件大小
function formatFileSize(size: number): string {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(1) + ' KB'
} else {
return (size / (1024 * 1024)).toFixed(1) + ' MB'
}
}
// 批量上传文件
async function uploadFiles(): Promise<string[]> {
const fileIds: string[] = []
if (selectedFiles.value.length === 0) return fileIds
isUploading.value = true
uni.showLoading({ title: '上传中...' })
try {
// 获取文件路径数组
const filePaths = selectedFiles.value.map(file => file.path)
// 使用fileAPI的批量上传方法
const result = await fileAPI.batchUploadFiles(filePaths, {
module: 'chatroom'
})
if (result.success && result.dataList) {
// 提取fileId数组
fileIds.push(...result.dataList.map(file => file.fileId!))
} else {
throw new Error(result.message || '文件上传失败')
}
return fileIds
} catch (error) {
console.error('文件上传失败:', error)
uni.showToast({ title: '文件上传失败', icon: 'none' })
throw error
} finally {
isUploading.value = false
uni.hideLoading()
selectedFiles.value = [] // 清空选中文件列表
}
}
// 文件下载和预览功能
// 从fileId中提取文件名
function getFileNameFromId(fileId: string): string {
// 简化实现实际项目中可以从fileId解析或通过API获取文件名
return fileId.split('/').pop() || fileId
}
// 下载文件
async function downloadFile(fileId: string) {
try {
// 使用fileAPI获取正确的文件下载URL
const downloadUrl = fileAPI.getDownloadUrl(fileId)
// 发起下载请求
uni.downloadFile({
url: downloadUrl,
header: {
Authorization: `Bearer ${uni.getStorageSync('token') || ''}`
},
success: (res) => {
if (res.statusCode === 200) {
// 保存文件到本地
uni.saveFile({
filePath: res.tempFilePath,
success: (saveRes) => {
uni.showToast({
title: '文件已保存',
icon: 'success'
})
// 打开文件
uni.openDocument({
filePath: saveRes.savedFilePath,
success: () => {
console.log('文件打开成功')
}
})
}
})
} else {
uni.showToast({
title: '文件下载失败',
icon: 'none'
})
}
},
fail: (error) => {
console.error('文件下载失败:', error)
uni.showToast({
title: '文件下载失败',
icon: 'none'
})
}
})
} catch (error) {
console.error('文件下载异常:', error)
uni.showToast({
title: '文件下载失败',
icon: 'none'
})
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
@@ -822,4 +1000,166 @@ function handleNewMessage(message: ChatRoomMessageVO) {
<style lang="scss" scoped>
@import "./chatRoom.scss";
// 文件上传相关样式
.file-preview-container {
padding: 10px;
background-color: #f8f8f8;
border-radius: 8px;
margin-bottom: 10px;
}
.file-preview-item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: white;
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid #e8e8e8;
&:last-child {
margin-bottom: 0;
}
}
.file-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: row;
}
.file-name {
font-size: 14px;
color: #333;
margin-right: 10px;
word-break: break-all;
}
.file-size {
font-size: 12px;
color: #999;
}
.file-actions {
margin-left: 10px;
}
.file-delete {
font-size: 12px;
color: #ff4d4f;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
&:hover {
background-color: rgba(255, 77, 79, 0.1);
}
}
// 输入区操作按钮
.input-row {
display: flex;
align-items: center;
gap: 10px;
}
.input-actions {
display: flex;
gap: 10px;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
cursor: pointer;
border-radius: 8px;
&:hover {
background-color: #f0f0f0;
}
}
.action-icon {
font-size: 20px;
margin-bottom: 2px;
}
.action-text {
font-size: 12px;
color: #666;
}
// 发送按钮状态
.send-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 消息中的文件列表
.message-files {
margin-top: 8px;
display: flex;
flex-direction: row;
gap: 8px;
}
.message-file-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 12px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 1);
transform: translateY(-1px);
}
}
.message-file-item .file-icon {
font-size: 16px;
margin-right: 8px;
}
.message-file-item .file-name {
font-size: 13px;
color: #1890ff;
word-break: break-all;
}
// 适配自己消息的文件样式
.self .message-files {
align-items: flex-end;
}
.self .message-file-item {
background-color: rgba(24, 144, 255, 0.1);
&:hover {
background-color: rgba(24, 144, 255, 0.2);
}
}
// 适配对方消息的文件样式
.other .message-files {
align-items: flex-start;
}
.other .message-file-item {
background-color: rgba(255, 255, 255, 0.8);
&:hover {
background-color: rgba(255, 255, 255, 1);
}
}
</style>

View File

@@ -2,293 +2,745 @@
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: navPaddingTop + 'px', height: navHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
</view>
<text class="nav-title">我的聊天室</text>
<view class="nav-capsule"></view>
</view>
<!-- 聊天室列表 -->
<scroll-view class="list" scroll-y="true" :style="{ marginTop: navHeight + 'px' }">
<view class="room-card" v-for="(room, index) in chatRooms" :key="index" @tap="enterRoom(room)">
<view class="room-avatar">
<text class="avatar-text">{{ room.guestName?.charAt(0) || '客' }}</text>
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: navPaddingTop + 'px', height: navTotalHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
</view>
<view class="room-info">
<view class="room-header">
<text class="room-name">{{ room.roomName || '聊天室' }}</text>
<text class="room-time">{{ formatTime(room.lastMessageTime) }}</text>
<text class="nav-title">我的聊天室</text>
<view class="nav-capsule"></view>
</view>
<!-- 用户筛选组件仅非guest用户可见 -->
<view class="filter-section" v-if="!isGuest" :style="{ marginTop: navTotalHeight + 'px' }">
<view class="filter-label">选择用户:</view>
<view class="filter-content">
<view class="guest-selector" @tap="showGuestSelector = true">
<text v-if="selectedGuestId" class="selected-guest">
{{ guestsList.find(g => g.userId === selectedGuestId)?.name || '请选择用户' }}
</text>
<text v-else class="placeholder">请选择用户</text>
<view class="selector-arrow">▼</view>
</view>
<view class="room-footer">
<text class="last-message">{{ getPlainTextPreview(room.lastMessage) }}</text>
<view class="unread-badge" v-if="room.unreadCount && room.unreadCount > 0">
<text class="badge-text">{{ room.unreadCount > 99 ? '99+' : room.unreadCount }}</text>
<view class="btn-group">
<view class="reset-btn" @tap="clearGuestSelect">重置</view>
<view class="query-btn" @tap="handleQuery">查询</view>
</view>
</view>
</view>
<!-- 聊天室列表 -->
<scroll-view class="list" scroll-y="true"
:style="{ marginTop: navTotalHeight + (isGuest ? 0 : 44) + 'px' }"
@scrolltolower="handleScrollToLower">
<view class="room-card" v-for="(room, index) in chatRooms" :key="index" @tap="enterRoom(room)">
<view class="room-avatar">
<text class="avatar-text">{{ room.guestName?.charAt(0) || '客' }}</text>
</view>
<view class="room-info">
<view class="room-header">
<text class="room-name">{{ room.roomName || '聊天室' }}</text>
<text class="room-time">{{ formatTime(room.lastMessageTime) }}</text>
</view>
<view class="room-footer">
<text class="last-message">{{ getPlainTextPreview(room.lastMessage) }}</text>
<view class="unread-badge" v-if="room.unreadCount && room.unreadCount > 0">
<text class="badge-text">{{ room.unreadCount > 99 ? '99+' : room.unreadCount }}</text>
</view>
</view>
</view>
<view class="room-status" :class="getStatusClass(room.status)">
<text class="status-dot"></text>
<text class="status-text">{{ getStatusText(room.status) }}</text>
</view>
</view>
<view class="room-status" :class="getStatusClass(room.status)">
<text class="status-dot"></text>
<text class="status-text">{{ getStatusText(room.status) }}</text>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="chatRooms.length === 0">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无聊天室</text>
<text class="empty-hint">点击"联系人工"创建新聊天室</text>
<!-- 空状态 -->
<view class="empty-state" v-if="chatRooms.length === 0">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无聊天室</text>
<text class="empty-hint">点击"联系人工"创建新聊天室</text>
</view>
<!-- 加载更多提示 -->
<view class="load-more" v-if="loading">
<text class="load-text">加载中...</text>
</view>
<view class="load-more" v-else-if="!hasMore && chatRooms.length > 0">
<text class="load-text">没有更多数据了</text>
</view>
</scroll-view>
<!-- 用户选择弹窗 -->
<view class="modal-mask" v-if="showGuestSelector" @tap="showGuestSelector = false"></view>
<view class="modal-content" v-if="showGuestSelector">
<view class="modal-header">
<text class="modal-title">选择用户</text>
</view>
<view class="modal-body">
<view class="guest-item" v-for="guest in guestsList" :key="guest.userId"
@tap="handleGuestSelect(guest)">
<text class="guest-name">{{ guest.name }} {{ guest.phone || '无电话' }}</text>
</view>
<view class="empty-item" v-if="guestsList.length === 0">
<text>暂无可选用户</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { workcaseChatAPI } from '@/api'
import type { ChatRoomVO, TbChatRoomDTO, PageRequest, ChatRoomMessageVO } from '@/types'
import { wsClient } from '@/utils/websocket'
import { WS_HOST } from '@/config'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { workcaseChatAPI } from '@/api'
import { guestAPI } from '@/api/sys/guest'
import type { ChatRoomVO, TbChatRoomDTO, PageRequest, ChatRoomMessageVO, TbGuestDTO } from '@/types'
import { wsClient } from '@/utils/websocket'
import { WS_HOST } from '@/config'
// 导航栏
const navPaddingTop = ref<number>(0)
const navHeight = ref<number>(44)
const capsuleHeight = ref<number>(32)
// 导航栏
const navPaddingTop = ref<number>(0)
const navHeight = ref<number>(44)
const navTotalHeight = ref<number>(44)
const capsuleHeight = ref<number>(32)
// 加载状态
const loading = ref<boolean>(false)
// 加载状态
const loading = ref<boolean>(false)
const loadingUsers = ref<boolean>(false)
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([])
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([])
// 生命周期
onMounted(() => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 20
// #ifdef MP-WEIXIN
try {
const menuButton = uni.getMenuButtonBoundingClientRect()
navPaddingTop.value = menuButton.top
capsuleHeight.value = menuButton.height
navHeight.value = menuButton.bottom + 8
} catch (e) {
// 分页相关
const currentPage = ref<number>(1)
const pageSize = ref<number>(10)
const total = ref<number>(0)
const hasMore = ref<boolean>(true)
// 用户筛选相关
const isGuest = ref(true)
const guestsList = ref<TbGuestDTO[]>([])
const selectedGuestId = ref<string>('')
const showGuestSelector = ref<boolean>(false)
// 计算属性根据选中的用户ID筛选聊天室
const filteredChatRooms = computed(() => {
if (!selectedGuestId.value) {
return chatRooms.value
}
return chatRooms.value.filter(room => room.guestId === selectedGuestId.value)
})
// 生命周期
onMounted(async () => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 20
// #ifdef MP-WEIXIN
try {
const menuButton = uni.getMenuButtonBoundingClientRect()
navPaddingTop.value = menuButton.top
capsuleHeight.value = menuButton.height
navHeight.value = menuButton.bottom + 8
} catch (e) {
navPaddingTop.value = statusBarHeight
navHeight.value = navPaddingTop.value + 44
}
// #endif
// #ifndef MP-WEIXIN
navPaddingTop.value = statusBarHeight
navHeight.value = navPaddingTop.value + 44
}
// #endif
// #ifndef MP-WEIXIN
navPaddingTop.value = statusBarHeight
navHeight.value = navPaddingTop.value + 44
// #endif
loadChatRooms()
initWebSocket()
})
// #endif
// 组件卸载时断开WebSocket
onUnmounted(() => {
disconnectWebSocket()
})
// 设置导航栏总高度
navTotalHeight.value = navHeight.value
// 加载聊天室列表
async function loadChatRooms() {
loading.value = true
try {
// 获取当前用户ID
const userId = uni.getStorageSync('userId') || ''
const pageRequest: PageRequest<TbChatRoomDTO> = {
filter: {
guestId: userId // 查询当前用户的聊天室
},
pageParam: {
pageNumber: 1,
pageSize: 50
}
}
const res = await workcaseChatAPI.getChatRoomPage(pageRequest)
if (res.success && res.pageDomain?.dataList) {
chatRooms.value = res.pageDomain.dataList
}
} catch (error) {
console.error('加载聊天室列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 检查用户类型
checkUserType()
// 格式化时间(兼容 iOS
function formatTime(time?: string): string {
if (!time) return ''
// iOS 不支持 "yyyy-MM-dd HH:mm:ss" 格式,需要转换为 "yyyy-MM-ddTHH:mm:ss"
const iosCompatibleTime = time.replace(' ', 'T')
const date = new Date(iosCompatibleTime)
if (isNaN(date.getTime())) return ''
// 加载聊天室列表
loadChatRooms()
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
if (diff < 172800000) return '昨天'
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 去除markdown语法并截取前10个字符
function getPlainTextPreview(text?: string): string {
if (!text) return '暂无消息'
// 去除markdown语法
let plainText = text
// 去除代码块
.replace(/```[\s\S]*?```/g, '[代码]')
// 去除行内代码
.replace(/`([^`]+)`/g, '$1')
// 去除粗体
.replace(/\*\*([^\*]+)\*\*/g, '$1')
// 去除斜体
.replace(/\*([^\*]+)\*/g, '$1')
// 去除链接
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// 去除标题标记
.replace(/^#{1,6}\s+/gm, '')
// 去除列表标记
.replace(/^[*-]\s+/gm, '')
// 去除多余的空白字符
.replace(/\s+/g, ' ')
.trim()
// 截取前10个字符
if (plainText.length > 10) {
return plainText.substring(0, 10) + '...'
}
return plainText
}
// 获取状态样式类
function getStatusClass(status?: string): string {
switch (status) {
case 'active': return 'status-active'
case 'waiting': return 'status-waiting'
case 'closed': return 'status-closed'
default: return 'status-waiting'
}
}
// 获取状态文本
function getStatusText(status?: string): string {
switch (status) {
case 'active': return '进行中'
case 'waiting': return '等待中'
case 'closed': return '已关闭'
default: return '未知'
}
}
// 进入聊天室
function enterRoom(room: ChatRoomVO) {
room.unreadCount = 0
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${room.roomId}&workcaseId=${room.workcaseId || ''}`
// 初始化WebSocket
initWebSocket()
})
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 组件卸载时断开WebSocket
onUnmounted(() => {
disconnectWebSocket()
})
// ==================== WebSocket连接管理 ====================
// 初始化WebSocket连接
async function initWebSocket() {
try {
const token = uni.getStorageSync('token') || ''
if (!token) {
console.warn('[chatRoomList] 未找到token跳过WebSocket连接')
return
}
// 构建WebSocket URL
// 开发环境ws://localhost:8180 或 ws://192.168.x.x:8180
// 生产环境wss://your-domain.com
const protocol = 'wss:' // 开发环境使用ws生产环境改为wss
// 小程序使用原生WebSocket端点不是SockJS端点
const wsUrl = `${protocol}//${WS_HOST}/urban-lifeline/workcase/ws/chat?token=${encodeURIComponent(token)}`
console.log('[chatRoomList] 开始连接WebSocket')
await wsClient.connect(wsUrl, token)
// 订阅聊天室列表更新频道
wsClient.subscribe('/topic/chat/list-update', handleListUpdate)
console.log('[chatRoomList] WebSocket连接成功已订阅列表更新频道')
} catch (error) {
console.error('[chatRoomList] WebSocket连接失败:', error)
}
}
// 断开WebSocket连接
function disconnectWebSocket() {
try {
wsClient.disconnect()
console.log('[chatRoomList] WebSocket已断开')
} catch (error) {
console.error('[chatRoomList] 断开WebSocket失败:', error)
}
}
// 处理列表更新消息
async function handleListUpdate(message: ChatRoomMessageVO) {
console.log('[chatRoomList] 收到列表更新消息:', message)
// 更新对应聊天室的lastMessage和lastMessageTime
const roomIndex = chatRooms.value.findIndex((r: ChatRoomVO) => r.roomId === message.roomId)
if (roomIndex !== -1) {
// 查询当前用户在该聊天室的未读数
let unreadCount = 0
// 检查用户类型
function checkUserType() {
try {
const userInfo = uni.getStorageSync('userInfo')
const userId = typeof userInfo === 'string' ? JSON.parse(userInfo).userId : userInfo?.userId
if (userId) {
const unreadResult = await workcaseChatAPI.getUnreadCount(message.roomId, userId)
if (unreadResult.success && unreadResult.data !== undefined) {
unreadCount = unreadResult.data
}
const parsedUserInfo = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
isGuest.value = parsedUserInfo.status === 'guest'
// 如果是非guest用户加载可选人员列表
if (!isGuest.value) {
loadGuestsList()
}
} catch (error) {
console.error('[chatRoomList] 查询未读数失败:', error)
console.error('检查用户类型失败:', error)
isGuest.value = true
}
}
// 加载可选人员列表
async function loadGuestsList() {
loadingUsers.value = true
try {
const res = await guestAPI.listGuest()
if (res.success && res.dataList) {
guestsList.value = res.dataList
}
} catch (error) {
console.error('加载可选人员列表失败:', error)
} finally {
loadingUsers.value = false
}
}
// 加载聊天室列表
async function loadChatRooms(isLoadMore = false) {
// 如果正在加载或没有更多数据,直接返回
if (loading.value || (!isLoadMore && !hasMore.value)) return
loading.value = true
try {
// 获取当前用户ID
const userId = uni.getStorageSync('userId') || ''
// 计算当前页码
const currentPageNum = isLoadMore ? currentPage.value + 1 : 1
const pageRequest : PageRequest<TbChatRoomDTO> = {
filter: {
guestId: isGuest.value ? userId : selectedGuestId.value // 如果是guest用户查询自己的聊天室否则根据选中的用户ID查询
},
pageParam: {
page: currentPageNum,
pageSize: pageSize.value
}
}
const res = await workcaseChatAPI.getChatRoomPage(pageRequest)
if (res.success && res.pageDomain?.dataList) {
const newRooms = res.pageDomain.dataList
total.value = res.pageDomain?.pageParam?.total || 0
// 根据是否加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
chatRooms.value = [...chatRooms.value, ...newRooms]
currentPage.value++
} else {
// 刷新:替换数据
chatRooms.value = newRooms
currentPage.value = 1
}
// 判断是否还有更多数据
hasMore.value = chatRooms.value.length < total.value
}
} catch (error) {
console.error('加载聊天室列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
// 如果是加载更多失败保持hasMore不变
if (!isLoadMore) {
hasMore.value = false
}
} finally {
loading.value = false
}
}
// 处理滚动到底部事件
function handleScrollToLower() {
if (hasMore.value && !loading.value) {
loadChatRooms(true)
}
}
// 处理用户选择
function handleGuestSelect(guest : TbGuestDTO) {
selectedGuestId.value = guest.userId || ''
showGuestSelector.value = false
// 重置分页状态
hasMore.value = true
loadChatRooms() // 根据选中的用户重新加载聊天室列表
}
// 清除用户选择
function clearGuestSelect() {
selectedGuestId.value = ''
// 重置分页状态
hasMore.value = true
loadChatRooms() // 重新加载所有聊天室列表
}
// 查询聊天室列表
function handleQuery() {
// 重置分页状态
hasMore.value = true
loadChatRooms() // 根据当前筛选条件重新加载聊天室列表
}
// 格式化时间(兼容 iOS
function formatTime(time ?: string) : string {
if (!time) return ''
// iOS 不支持 "yyyy-MM-dd HH:mm:ss" 格式,需要转换为 "yyyy-MM-ddTHH:mm:ss"
const iosCompatibleTime = time.replace(' ', 'T')
const date = new Date(iosCompatibleTime)
if (isNaN(date.getTime())) return ''
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
if (diff < 172800000) return '昨天'
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 去除markdown语法并截取前10个字符
function getPlainTextPreview(text ?: string) : string {
if (!text) return '暂无消息'
// 去除markdown语法
let plainText = text
// 去除代码块
.replace(/```[\s\S]*?```/g, '[代码]')
// 去除行内代码
.replace(/`([^`]+)`/g, '$1')
// 去除粗体
.replace(/\*\*([^\*]+)\*\*/g, '$1')
// 去除斜体
.replace(/\*([^\*]+)\*/g, '$1')
// 去除链接
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// 去除标题标记
.replace(/^#{1,6}\s+/gm, '')
// 去除列表标记
.replace(/^[*-]\s+/gm, '')
// 去除多余的空白字符
.replace(/\s+/g, ' ')
.trim()
// 截取前10个字符
if (plainText.length > 10) {
return plainText.substring(0, 10) + '...'
}
return plainText
}
// 获取状态样式类
function getStatusClass(status ?: string) : string {
switch (status) {
case 'active': return 'status-active'
case 'waiting': return 'status-waiting'
case 'closed': return 'status-closed'
default: return 'status-waiting'
}
}
// 获取状态文本
function getStatusText(status ?: string) : string {
switch (status) {
case 'active': return '进行中'
case 'waiting': return '等待中'
case 'closed': return '已关闭'
default: return '未知'
}
}
// 进入聊天室
function enterRoom(room : ChatRoomVO) {
room.unreadCount = 0
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${room.roomId}&workcaseId=${room.workcaseId || ''}`
})
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
// ==================== WebSocket连接管理 ====================
// 初始化WebSocket连接
async function initWebSocket() {
try {
const token = uni.getStorageSync('token') || ''
if (!token) {
console.warn('[chatRoomList] 未找到token跳过WebSocket连接')
return
}
// 构建WebSocket URL
// 开发环境ws://localhost:8180 或 ws://192.168.x.x:8180
// 生产环境wss://your-domain.com
const protocol = 'wss:' // 开发环境使用ws生产环境改为wss
// 小程序使用原生WebSocket端点不是SockJS端点
const wsUrl = `${protocol}//${WS_HOST}/urban-lifeline/workcase/ws/chat?token=${encodeURIComponent(token)}`
console.log('[chatRoomList] 开始连接WebSocket')
await wsClient.connect(wsUrl, token)
// 订阅聊天室列表更新频道
wsClient.subscribe('/topic/chat/list-update', handleListUpdate)
console.log('[chatRoomList] WebSocket连接成功已订阅列表更新频道')
} catch (error) {
console.error('[chatRoomList] WebSocket连接失败:', error)
}
}
// 断开WebSocket连接
function disconnectWebSocket() {
try {
wsClient.disconnect()
console.log('[chatRoomList] WebSocket已断开')
} catch (error) {
console.error('[chatRoomList] 断开WebSocket失败:', error)
}
}
// 处理列表更新消息
async function handleListUpdate(message : ChatRoomMessageVO) {
console.log('[chatRoomList] 收到列表更新消息:', message)
// 更新对应聊天室的lastMessage和lastMessageTime
const roomIndex = chatRooms.value.findIndex((r : ChatRoomVO) => r.roomId === message.roomId)
if (roomIndex !== -1) {
// 查询当前用户在该聊天室的未读数
let unreadCount = 0
try {
const userInfo = uni.getStorageSync('userInfo')
const userId = typeof userInfo === 'string' ? JSON.parse(userInfo).userId : userInfo?.userId
if (userId) {
const unreadResult = await workcaseChatAPI.getUnreadCount(message.roomId, userId)
if (unreadResult.success && unreadResult.data !== undefined) {
unreadCount = unreadResult.data
}
}
} catch (error) {
console.error('[chatRoomList] 查询未读数失败:', error)
}
chatRooms.value[roomIndex] = {
...chatRooms.value[roomIndex],
lastMessage: message.content || '',
lastMessageTime: message.sendTime || '',
unreadCount: unreadCount
}
// 将更新的聊天室移到列表顶部
const updatedRoom = chatRooms.value[roomIndex]
chatRooms.value.splice(roomIndex, 1)
chatRooms.value.unshift(updatedRoom)
}
chatRooms.value[roomIndex] = {
...chatRooms.value[roomIndex],
lastMessage: message.content || '',
lastMessageTime: message.sendTime || '',
unreadCount: unreadCount
}
// 将更新的聊天室移到列表顶部
const updatedRoom = chatRooms.value[roomIndex]
chatRooms.value.splice(roomIndex, 1)
chatRooms.value.unshift(updatedRoom)
}
}
</script>
<style lang="scss" scoped>
@import "./chatRoomList.scss";
@import "./chatRoomList.scss";
// 用户筛选组件样式
.filter-section {
position: fixed;
top: var(--nav-height);
left: 0;
right: 0;
height: 44px;
background: #fff;
border-bottom: 1px solid #eee;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
z-index: 10;
}
.filter-label {
font-size: 14px;
color: #333;
margin-right: 8px;
}
.filter-content {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.guest-selector {
flex: 1;
height: 32px;
border: 1px solid #ddd;
border-radius: 16px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f9f9f9;
cursor: pointer;
transition: all 0.3s ease;
}
.guest-selector:active {
background: #e9e9e9;
border-color: #bbb;
}
.selected-guest {
font-size: 14px;
color: #333;
}
.placeholder {
font-size: 14px;
color: #999;
}
.selector-arrow {
font-size: 12px;
color: #666;
transition: transform 0.3s ease;
}
.filter-section:active .selector-arrow {
transform: rotate(180deg);
}
/* 按钮组样式 */
.btn-group {
display: flex;
flex-direction: row;
gap: 8px;
}
.reset-btn,
.query-btn {
height: 32px;
padding: 0 16px;
border-radius: 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.reset-btn {
background: #f5f5f5;
border: 1px solid #ddd;
color: #666;
}
.reset-btn:active {
background: #e9e9e9;
border-color: #bbb;
}
.query-btn {
background: #1989fa;
border: 1px solid #1989fa;
color: #fff;
}
.query-btn:active {
background: #0066cc;
border-color: #0066cc;
}
// 弹窗样式
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 400px;
background: #fff;
border-radius: 20px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.25);
z-index: 1001;
animation: slideUp 0.3s ease;
overflow: hidden;
}
/* 弹窗动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.modal-close {
font-size: 24px;
color: #999;
cursor: pointer;
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.modal-close:active {
background: #f0f0f0;
color: #666;
transform: rotate(90deg);
}
.modal-body {
max-height: 350px;
overflow-y: auto;
padding: 0;
}
/* 滚动条样式 */
.modal-body::-webkit-scrollbar {
width: 6px;
}
.modal-body::-webkit-scrollbar-track {
background: #f1f1f1;
}
.modal-body::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.modal-body::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.guest-item {
display: flex;
flex-direction: row;
padding: 16px 20px;
cursor: pointer;
transition: all 0.3s ease;
border-bottom: 1px solid #f5f5f5;
}
.guest-item:last-child {
border-bottom: none;
}
.guest-item:active {
background-color: #f8f8f8;
transform: translateX(5px);
}
.guest-item:active .guest-name {
color: #1989fa;
}
.guest-name {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
transition: color 0.3s ease;
}
.guest-phone {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
.empty-item {
padding: 30px 20px;
text-align: center;
color: #999;
font-size: 14px;
background: #fafafa;
}
.empty-item::before {
content: "👤";
font-size: 48px;
display: block;
margin-bottom: 12px;
opacity: 0.5;
}
/* 加载更多样式 */
.load-more {
padding: 20px;
text-align: center;
background: #fff;
border-top: 1px solid #eee;
margin-top: 0;
}
.load-text {
font-size: 14px;
color: #999;
}
</style>

View File

@@ -7,7 +7,8 @@
position: relative;
// 多种安全区域适配方式
padding-top: env(safe-area-inset-top);
padding-top: constant(safe-area-inset-top); /* 兼容iOS < 11.2 */
padding-top: constant(safe-area-inset-top);
/* 兼容iOS < 11.2 */
box-sizing: border-box;
}
@@ -18,12 +19,12 @@
left: 0;
right: 0;
display: flex;
flex-direction: row;
flex-direction: row;
align-items: center;
padding: 0 16px;
z-index: 100;
box-sizing: border-box;
// 小程序需要为右侧胶囊按钮留出空间
/* #ifdef MP-WEIXIN */
padding-right: 100px;
@@ -83,7 +84,7 @@
// 退出按钮特殊样式
.logout-btn {
background: rgba(255, 59, 48, 0.1);
.btn-text {
color: #ff3b30;
}
@@ -117,10 +118,25 @@
border: none !important;
}
.r1 { width: 260px; height: 260px; }
.r2 { width: 200px; height: 200px; }
.r3 { width: 150px; height: 150px; }
.r4 { width: 110px; height: 110px; }
.r1 {
width: 260px;
height: 260px;
}
.r2 {
width: 200px;
height: 200px;
}
.r3 {
width: 150px;
height: 150px;
}
.r4 {
width: 110px;
height: 110px;
}
.robot {
position: relative;
@@ -128,10 +144,10 @@
width: 140px;
height: 140px;
/* 父容器加一层径向渐变背景,模拟外层模糊光晕 */
background: radial-gradient(circle at center,
rgba(180, 220, 255, 0.5) 0%,
rgba(180, 220, 255, 0.25) 50%,
transparent 75%);
background: radial-gradient(circle at center,
rgba(180, 220, 255, 0.5) 0%,
rgba(180, 220, 255, 0.25) 50%,
transparent 75%);
border-radius: 50%;
display: flex;
align-items: center;
@@ -164,16 +180,30 @@
.float-tag {
position: absolute;
padding: 6px 12px;
background: transparent; /* 去掉背景色,和左侧一致 */
border: none; /* 去掉边框 */
border-radius: 0; /* 保持直角如果需要圆角也可以改回16px */
background: transparent;
/* 去掉背景色,和左侧一致 */
border: none;
/* 去掉边框 */
border-radius: 0;
/* 保持直角如果需要圆角也可以改回16px */
font-size: 12px;
color: #666;
}
.t1 { right: 20px; top: 40px; }
.t2 { left: 20px; top: 80px; }
.t3 { right: 30px; bottom: 50px; }
.t1 {
right: 20px;
top: 40px;
}
.t2 {
left: 20px;
top: 80px;
}
.t3 {
right: 30px;
bottom: 50px;
}
.greeting {
text-align: left;
@@ -199,7 +229,7 @@
background: #fff;
padding: 12px 16px;
border-radius: 12px;
margin: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
@@ -373,63 +403,86 @@
padding-left: 48px;
}
// 底部操作区域
// 底部操作区域(如果没有定义,需要添加)
.bottom-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
// background: rgba(240, 241, 246, 0.95);
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 50;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 50;
background-color: #fff; // 增加背景色,避免穿透
}
// 快捷按钮横向滚动
// 快捷按钮横向滚动 - 修复横向滚动问题
.quick-scroll {
white-space: nowrap;
margin-bottom: 12px;
width: 100%;
margin-bottom: 12px;
/* 核心必须设置固定高度否则scroll-view可能无法正常工作 */
height: 50px;
/* 确保scroll-view能正确处理滚动 */
overflow-x: scroll;
display: flex;
flex-direction: row;
-webkit-overflow-scrolling: touch;
/* 隐藏滚动条但保留滚动功能 */
scrollbar-width: none;
-ms-overflow-style: none;
}
.quick-inner {
display: flex;
flex-direction: row;
align-items: center; /* ✅ 垂直居中 */
padding: 0 16px;
height: 100%;
}
/* 隐藏滚动条 */
.quick-scroll::-webkit-scrollbar {
display: none;
}
.quick-list {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 8px;
display: inline-flex; /* 使用inline-flex确保宽度随内容增长触发横向滚动 */
flex-direction: row;
align-items: center;
gap: 8px;
padding: 0 16px;
/* 关键:让容器宽度自动适应内容,超出父容器时触发滚动 */
width: auto;
/* 确保垂直居中 */
height: 100%;
}
.quick-btn {
margin: 0;
position: relative;
box-sizing: border-box;
flex-shrink: 0;
flex-grow: 0;
flex-basis: auto;
align-content: stretch;
min-height: 0px;
min-width: 0px;
overflow: hidden;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: none;
display: flex;
flex-direction: row;
padding: 10px 24px;
color: black;
/* 保持原有样式 */
margin: 0;
position: relative;
box-sizing: border-box;
background: #fff;
border-radius: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: none;
padding: 10px 24px;
color: black;
/* 确保按钮不换行 */
white-space: nowrap;
/* 关键防止flex子项被压缩确保产生横向溢出以便滚动 */
flex-shrink: 0;
/* 按钮内部使用flex布局 */
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
/* 确保按钮高度合适 */
height: 36px;
}
.quick-btn.has-icon {
// background: linear-gradient(90deg, #173294 0%, #4a6fd9 100%);
border: none;
padding: 10px 24px;
/* 保持原有样式 */
}
.quick-btn.has-icon .quick-text {
// color: #fff;
font-weight: 500;
/* 保持原有样式 */
}
.quick-icon {
@@ -443,6 +496,7 @@
height: 20px;
background: #d0d5dd;
margin: 0 4px;
flex-shrink: 0;
}
.quick-text {
@@ -696,10 +750,14 @@
}
@keyframes typing-bounce {
0%, 80%, 100% {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
@@ -953,3 +1011,77 @@
font-size: 14px;
color: #666;
}
// 操作选择弹窗样式
.operation-select-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
width: 80%;
max-width: 400px;
background: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modal-header {
text-align: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.operation-select-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.operation-select-item {
display: flex;
align-items: center;
padding: 16px;
background: #f5f7fa;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.operation-select-item:active {
background: #e4e7ed;
transform: scale(0.98);
}
.select-icon {
font-size: 24px;
margin-right: 12px;
}
.select-text {
font-size: 16px;
color: #333;
}

View File

@@ -7,13 +7,13 @@
<button class="workcase-btn" @tap="showUserSelector">
<text class="btn-text">{{userInfo.username || '切换'}}</text>
</button>
<button class="workcase-btn" @tap="goToChatRoomList">
<!-- <button class="workcase-btn" @tap="showOperationSelect('chat')">
<text class="btn-text">聊天室</text>
</button>
<button class="workcase-btn" @tap="goToWorkList">
<button class="workcase-btn" @tap="showOperationSelect('workcase')">
<image class="btn-icon" src="/static/imgs/case.svg" />
<text class="btn-text">工单</text>
</button>
</button> -->
</view>
</view>
<!-- 欢迎区域(机器人+浮动标签) -->
@@ -41,7 +41,8 @@
</view>
<!-- 聊天消息区域 -->
<scroll-view class="chat-messages" scroll-y="true" :scroll-top="scrollTop" scroll-with-animation="true" :class="{ started: messages.length > 0 }">
<scroll-view class="chat-messages" scroll-y="true" :scroll-top="scrollTop" scroll-with-animation="true"
:class="{ started: messages.length > 0 }">
<!-- AI初始消息 -->
<view class="ai-initial-msg" v-if="messages.length === 0">
<text class="ai-msg-text">您好,我是泰豪小电智能客服。请描述您的问题,我会尽力协助。</text>
@@ -59,7 +60,8 @@
</view>
<!-- 用户消息的文件列表(在气泡外面) -->
<view v-if="item.files && item.files.length > 0" class="message-files">
<view v-for="fileId in item.files" :key="fileId" class="message-file-item" @tap="previewFile(fileId)">
<view v-for="fileId in item.files" :key="fileId" class="message-file-item"
@tap="previewFile(fileId)">
<view v-if="isImageFileById(fileId)" class="file-thumb image">
<image :src="getFileDownloadUrl(fileId)" mode="aspectFill" class="file-img" />
</view>
@@ -86,69 +88,74 @@
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
<rich-text v-else :nodes="renderMarkdown(item.content)" class="message-rich-text"></rich-text>
<rich-text v-else :nodes="renderMarkdown(item.content)"
class="message-rich-text"></rich-text>
</view>
</view>
<text class="message-time">{{item.time}}</text>
</view>
</view>
</scroll-view>
<!-- 底部操作区域 -->
<view class="bottom-area">
<!-- 快捷按钮横向滚动 -->
<scroll-view class="quick-scroll" scroll-x="true">
<view class="quick-list">
<view class="quick-btn has-icon" @tap="contactHuman">
<text class="quick-icon">☎</text>
<text class="quick-text">联系人工</text>
</view>
<view class="quick-btn has-icon" @tap="showCreator">
<text class="quick-icon">⊕</text>
<text class="quick-text">创建工单</text>
</view>
<view class="quick-divider"></view>
<view class="quick-btn" @tap="handleQuickQuestion('查询质保状态')">
<text class="quick-text">查询质保状态</text>
</view>
<view class="quick-btn" @tap="handleQuickQuestion('发动机无法启动')">
<text class="quick-text">发动机无法启动</text>
</view>
<!-- 快捷按钮横向滚动 -->
<scroll-view class="quick-scroll" scroll-x="true" show-scrollbar="false">
<view class="quick-inner">
<view class="quick-btn has-icon" @tap="showOperationSelect('chat')">
<text class="quick-icon">☎</text>
<text class="quick-text">联系人工</text>
</view>
</scroll-view>
<view class="quick-btn has-icon" @tap="showOperationSelect('workcase')">
<text class="quick-icon">⊕</text>
<text class="quick-text">创建工单</text>
</view>
<view class="quick-divider"></view>
<view class="quick-btn" @tap="handleQuickQuestion('查询质保状态')">
<text class="quick-text">查询质保状态</text>
</view>
<view class="quick-btn" @tap="handleQuickQuestion('发动机无法启动')">
<text class="quick-text">发动机无法启动</text>
</view>
<view class="quick-btn" @tap="handleQuickQuestion('申请上门维修')">
<text class="quick-text">申请上门维修服务</text>
</view>
<view class="quick-btn" @tap="handleQuickQuestion('查询维修进度')">
<text class="quick-text">查询维修进度</text>
</view>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="chat-input-wrap">
<!-- 已上传文件预览 -->
<view v-if="uploadedFiles.length > 0" class="uploaded-files">
<view v-for="(file, index) in uploadedFiles" :key="file.id || index" class="uploaded-file-item">
<view v-if="isImageFile(file)" class="file-preview-image">
<image :src="getFilePreviewUrl(file)" mode="aspectFill" class="preview-img" />
</view>
<view v-else class="file-preview-doc">
<text class="doc-icon">📄</text>
</view>
<text class="file-name">{{file.name || '文件'}}</text>
<view class="remove-file-btn" @tap="removeUploadedFile(index)">
<text class="remove-icon">✕</text>
</view>
<!-- 输入区域 -->
<view class="chat-input-wrap">
<!-- 已上传文件预览 -->
<view v-if="uploadedFiles.length > 0" class="uploaded-files">
<view v-for="(file, index) in uploadedFiles" :key="file.id || index" class="uploaded-file-item">
<view v-if="isImageFile(file)" class="file-preview-image">
<image :src="getFilePreviewUrl(file)" mode="aspectFill" class="preview-img" />
</view>
</view>
<!-- 输入框 -->
<view class="input-row">
<view class="upload-btn" :class="{ uploading: isUploading }" @tap="showUploadOptions">
<text v-if="isUploading" class="upload-icon">⏳</text>
<text v-else class="upload-icon">📎</text>
<view v-else class="file-preview-doc">
<text class="doc-icon">📄</text>
</view>
<input class="chat-input" v-model="inputText" placeholder="输入问题 来问问我~" @confirm="sendMessage" />
<view class="send-btn" :class="{ active: inputText.trim() || uploadedFiles.length > 0 }" @tap="sendMessage">
<text class="send-icon"></text>
<text class="file-name">{{file.name || '文件'}}</text>
<view class="remove-file-btn" @tap="removeUploadedFile(index)">
<text class="remove-icon"></text>
</view>
</view>
</view>
<!-- 输入框 -->
<view class="input-row">
<view class="upload-btn" :class="{ uploading: isUploading }" @tap="showUploadOptions">
<text v-if="isUploading" class="upload-icon">⏳</text>
<text v-else class="upload-icon">📎</text>
</view>
<input class="chat-input" v-model="inputText" placeholder="输入问题 来问问我~" @confirm="sendMessage" />
<view class="send-btn" :class="{ active: inputText.trim() || uploadedFiles.length > 0 }"
@tap="sendMessage">
<text class="send-icon">➤</text>
</view>
</view>
</view>
</view>
<!-- 设备代码输入弹窗 -->
<view class="device-code-modal" v-if="showDeviceCodeDialog">
<view class="modal-mask" @tap="cancelDeviceCodeInput"></view>
@@ -157,13 +164,8 @@
<text class="modal-title">请输入设备代码</text>
</view>
<view class="modal-body">
<input
class="device-code-input"
v-model="deviceCodeInput"
placeholder="请输入设备代码"
focus
@confirm="confirmDeviceCodeInput"
/>
<input class="device-code-input" v-model="deviceCodeInput" placeholder="请输入设备代码" focus
@confirm="confirmDeviceCodeInput" />
</view>
<view class="modal-footer">
<button class="modal-btn cancel" @tap="cancelDeviceCodeInput">
@@ -176,6 +178,28 @@
</view>
</view>
<!-- 操作选择弹窗 -->
<view class="operation-select-modal" v-if="showOperationSelectDialog">
<view class="modal-mask" @tap="cancelOperationSelect"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">{{ operationSelectTitle }}</text>
</view>
<view class="modal-body">
<view class="operation-select-list">
<view class="operation-select-item" @tap="handleOperationSelect('existing')">
<text class="select-icon">📋</text>
<text class="select-text">进入已有{{ operationSelectType === 'chat' ? '聊天室' : '工单' }}</text>
</view>
<view class="operation-select-item" @tap="handleOperationSelect('new')">
<text class="select-icon"></text>
<text class="select-text">创建新{{ operationSelectType === 'chat' ? '聊天室' : '工单' }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 手机号授权弹窗 -->
<view class="phone-auth-modal" v-if="showPhoneAuthModal">
<view class="modal-mask"></view>
@@ -237,11 +261,11 @@
import { AGENT_ID } from '@/config'
// 前端消息展示类型
interface ChatMessageItem {
type: 'user' | 'bot'
content: string
time: string
actions?: string[] | null
files?: string[] // 文件ID数组
type : 'user' | 'bot'
content : string
time : string
actions ?: string[] | null
files ?: string[] // 文件ID数组
}
const agentId = AGENT_ID
// 响应式数据
@@ -268,7 +292,7 @@
userId: ''
})
const userType = ref(false)
// 是否显示手机号授权弹窗
const showPhoneAuthModal = ref(false)
// 临时保存的微信登录code
@@ -283,6 +307,12 @@
const deviceCodeInput = ref<string>('') // 弹窗中的设备代码输入
const pendingAction = ref<'workcase' | 'human' | ''>('') // 待执行的操作类型
// 操作选择弹窗相关
const showOperationSelectDialog = ref<boolean>(false) // 是否显示操作选择弹窗
const operationSelectType = ref<'chat' | 'workcase'>('chat') // 选择类型chat=聊天室, workcase=工单
const operationSelectTitle = ref<string>('') // 弹窗标题
const pendingOperation = ref<'existing' | 'new' | ''>('') // 待执行的操作existing=进入已有, new=创建新的
// 初始化用户信息
async function initUserInfo() {
try {
@@ -290,7 +320,7 @@
const cachedWechatId = uni.getStorageSync('wechatId')
const cachedUserInfo = uni.getStorageSync('userInfo')
const cachedToken = uni.getStorageSync('token')
if (cachedWechatId && cachedUserInfo && cachedToken) {
// 有完整缓存,直接使用
const parsedUserInfo = typeof cachedUserInfo === 'string' ? JSON.parse(cachedUserInfo) : cachedUserInfo
@@ -300,14 +330,14 @@
phone: parsedUserInfo.phone || '',
userId: parsedUserInfo.userId || ''
}
// 判断用户类型
if (parsedUserInfo.status === 'guest') {
userType.value = false
} else {
userType.value = true
}
console.log('使用缓存的用户信息:', userInfo.value)
return
}
@@ -324,7 +354,7 @@
// 自动登录
async function autoLogin() {
uni.showLoading({ title: '初始化中...' })
try {
// 使用 wx.login 获取 code
uni.login({
@@ -332,10 +362,10 @@
success: async (loginRes) => {
console.log('微信登录成功code:', loginRes.code)
uni.hideLoading()
// 保存code等待手机号授权后使用
tempWechatCode.value = loginRes.code
// 显示手机号授权弹窗
showPhoneAuthModal.value = true
},
@@ -345,7 +375,7 @@
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
}
})
} catch (error: any) {
} catch (error : any) {
console.error('自动登录失败:', error)
uni.hideLoading()
uni.showToast({
@@ -362,7 +392,7 @@
uni.removeStorageSync('userInfo')
uni.removeStorageSync('loginDomain')
uni.removeStorageSync('wechatId')
uni.showLoading({ title: '登录中...' })
try {
const res = await guestAPI.identify({
@@ -379,7 +409,7 @@
userInfo.value.userId = loginDomain.user?.userId || ''
console.log('identify成功:', loginDomain)
uni.showToast({ title: '登录成功', icon: 'success' })
if(loginDomain.user.status == 'guest') {
if (loginDomain.user.status == 'guest') {
userType.value = false
} else {
userType.value = true
@@ -394,12 +424,12 @@
}
// 获取手机号回调(保留用于正式环境)
async function onGetPhoneNumber(e: any) {
async function onGetPhoneNumber(e : any) {
console.log('获取手机号回调:', e)
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
console.error('获取手机号失败:', e.detail)
// 如果是权限问题,提示用户
if (e.detail.errno === 102) {
uni.showModal({
@@ -418,20 +448,20 @@
// 关闭弹窗
showPhoneAuthModal.value = false
uni.showLoading({ title: '登录中...' })
try {
const code = tempWechatCode.value
const wechatId = code.substring(0, 20) // 使用部分code作为临时ID
// 获取手机号授权返回的数据
const phoneCode = e.detail.code // 手机号授权code推荐使用
const encryptedData = e.detail.encryptedData // 加密数据
const iv = e.detail.iv // 解密向量
console.log('手机号授权数据:', { code, phoneCode, encryptedData: encryptedData?.substring(0, 50) + '...', iv })
// 调用 identify 接口
// 后端可以选择使用 phoneCode 或 encryptedData+iv 来解密手机号
const identifyRes = await guestAPI.identify({
@@ -442,18 +472,18 @@
iv: iv, // 解密向量旧版API
loginType: 'wechat_miniprogram'
})
uni.hideLoading()
if (identifyRes.success && identifyRes.data) {
const loginDomain = identifyRes.data
// 保存登录信息
uni.setStorageSync('token', loginDomain.token || '')
uni.setStorageSync('userInfo', JSON.stringify(loginDomain.user))
uni.setStorageSync('loginDomain', JSON.stringify(loginDomain))
uni.setStorageSync('wechatId', wechatId)
// 更新用户信息
userInfo.value = {
wechatId: wechatId,
@@ -461,14 +491,14 @@
phone: loginDomain.user?.phone || '',
userId: loginDomain.user?.userId || ''
}
// 判断用户类型
if (loginDomain.user?.status === 'guest') {
userType.value = false
} else {
userType.value = true
}
console.log('手机号授权登录成功:', userInfo.value)
uni.showToast({ title: '登录成功', icon: 'success' })
} else {
@@ -479,7 +509,7 @@
// 登录失败,重新显示授权弹窗
showPhoneAuthModal.value = true
}
} catch (error: any) {
} catch (error : any) {
console.error('手机号授权登录失败:', error)
uni.hideLoading()
uni.showToast({
@@ -492,7 +522,7 @@
}
// 选择模拟用户(测试用)
async function selectMockUser(phone: string, name: string, wechatId: string) {
async function selectMockUser(phone : string, name : string, wechatId : string) {
showPhoneAuthModal.value = false
uni.showLoading({ title: '登录中...' })
@@ -505,18 +535,18 @@
mockMode: true,
loginType: 'wechat_miniprogram'
})
uni.hideLoading()
if (identifyRes.success && identifyRes.data) {
const loginDomain = identifyRes.data
// 保存登录信息
uni.setStorageSync('token', loginDomain.token || '')
uni.setStorageSync('userInfo', JSON.stringify(loginDomain.user))
uni.setStorageSync('loginDomain', JSON.stringify(loginDomain))
uni.setStorageSync('wechatId', wechatId)
// 更新用户信息
userInfo.value = {
wechatId: wechatId,
@@ -524,14 +554,14 @@
phone: phone,
userId: loginDomain.user?.userId || ''
}
// 判断用户类型
if (loginDomain.user?.status === 'guest') {
userType.value = false
} else {
userType.value = true
}
console.log('模拟登录成功:', userInfo.value)
uni.showToast({ title: `${name} 登录成功`, icon: 'success' })
} else {
@@ -541,7 +571,7 @@
})
showPhoneAuthModal.value = true
}
} catch (error: any) {
} catch (error : any) {
console.error('模拟登录失败:', error)
uni.hideLoading()
uni.showToast({
@@ -570,7 +600,7 @@
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight || 0
// #ifdef MP-WEIXIN
// 获取胶囊按钮信息仅小程序计算header位置
try {
@@ -602,7 +632,7 @@
})
// 添加用户消息(包含文件)
const userMessage: ChatMessageItem = {
const userMessage : ChatMessageItem = {
type: 'user',
content: text,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
@@ -640,7 +670,7 @@
}
// 准备流式对话(包含文件)
const prepareData: ChatPrepareData = {
const prepareData : ChatPrepareData = {
chatId: chatId.value,
query: query,
agentId: agentId,
@@ -776,7 +806,7 @@
}
// 检查并获取设备代码
function checkDeviceCode(action: 'workcase' | 'human') {
function checkDeviceCode(action : 'workcase' | 'human') {
if (!deviceCode.value) {
// 如果没有设备代码,显示输入弹窗
pendingAction.value = action
@@ -881,7 +911,7 @@
icon: 'none'
})
}
} catch (error: any) {
} catch (error : any) {
uni.hideLoading()
console.error('创建聊天室失败:', error)
uni.showToast({
@@ -891,6 +921,47 @@
}
}
// 显示操作选择弹窗
function showOperationSelect(type : 'chat' | 'workcase') {
operationSelectType.value = type
operationSelectTitle.value = type === 'chat' ? '选择聊天室操作' : '选择工单操作'
showOperationSelectDialog.value = true
}
// 处理操作选择
function handleOperationSelect(operation : 'existing' | 'new') {
showOperationSelectDialog.value = false
pendingOperation.value = operation
if (operation === 'existing') {
// 进入已有
if (operationSelectType.value === 'chat') {
// 进入已有聊天室
goToChatRoomList()
} else {
// 进入已有工单
goToWorkList()
}
} else {
// 创建新的
if (operationSelectType.value === 'chat') {
// 创建新聊天室(联系人工)
contactHuman()
} else {
// 创建新工单
showCreator()
}
}
}
// 取消操作选择
function cancelOperationSelect() {
showOperationSelectDialog.value = false
operationSelectType.value = 'chat'
operationSelectTitle.value = ''
pendingOperation.value = ''
}
// 直接跳转到工单详情页的 create 模式(复用 workcaseDetail 页面)
async function showCreator() {
// 检查设备代码
@@ -1023,7 +1094,7 @@
sourceType: ['album'],
success: (res) => {
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
res.tempFilePaths.forEach((filePath: string) => {
res.tempFilePaths.forEach((filePath : string) => {
uploadSingleFile(filePath)
})
}
@@ -1039,15 +1110,15 @@
count: 5,
type: 'file',
extension: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt'],
success: (res: any) => {
success: (res : any) => {
console.log('选择文件成功:', res)
if (res.tempFiles && res.tempFiles.length > 0) {
res.tempFiles.forEach((file: any) => {
res.tempFiles.forEach((file : any) => {
uploadSingleFile(file.path)
})
}
},
fail: (err: any) => {
fail: (err : any) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
@@ -1065,15 +1136,15 @@
uni.chooseFile({
count: 5,
extension: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'],
success: (res: any) => {
success: (res : any) => {
console.log('选择文件成功:', res)
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
res.tempFilePaths.forEach((filePath: string) => {
res.tempFilePaths.forEach((filePath : string) => {
uploadSingleFile(filePath)
})
}
},
fail: (err: any) => {
fail: (err : any) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
@@ -1091,7 +1162,7 @@
}
// 上传单个文件
async function uploadSingleFile(filePath: string) {
async function uploadSingleFile(filePath : string) {
console.log('开始上传文件:', filePath)
if (!agentId) {
@@ -1112,7 +1183,7 @@
} else {
uni.showToast({ title: result.message || '上传失败', icon: 'none' })
}
} catch (error: any) {
} catch (error : any) {
console.error('文件上传失败:', error)
uni.showToast({ title: '上传失败: ' + (error.message || '未知错误'), icon: 'none' })
} finally {
@@ -1122,27 +1193,27 @@
}
// 移除已上传的文件
function removeUploadedFile(index: number) {
function removeUploadedFile(index : number) {
uploadedFiles.value.splice(index, 1)
}
// 判断是否为图片文件
function isImageFile(file: DifyFileInfo): boolean {
function isImageFile(file : DifyFileInfo) : boolean {
return file.type === 'image' || file.mime_type?.startsWith('image/') || false
}
// 获取文件预览URL
function getFilePreviewUrl(file: DifyFileInfo): string {
function getFilePreviewUrl(file : DifyFileInfo) : string {
return file.preview_url || file.source_url || file.url || ''
}
// 获取文件下载URL通过文件ID
function getFileDownloadUrl(fileId: string): string {
function getFileDownloadUrl(fileId : string) : string {
return fileAPI.getDownloadUrl(fileId)
}
// 判断文件ID对应的文件是否为图片
function isImageFileById(fileId: string): boolean {
function isImageFileById(fileId : string) : boolean {
// 从缓存中查找文件信息
const file = fileInfoCache.value.get(fileId)
if (file) {
@@ -1157,13 +1228,13 @@
}
// 获取文件名(从缓存)
function getFileName(fileId: string): string {
function getFileName(fileId : string) : string {
const file = fileInfoCache.value.get(fileId)
return file?.name || fileId.substring(0, 8) + '...'
}
// 文件预览
function previewFile(fileId: string) {
function previewFile(fileId : string) {
const url = getFileDownloadUrl(fileId)
// 如果是图片,使用图片预览
if (isImageFileById(fileId)) {
@@ -1191,7 +1262,6 @@
})
}
}
</script>
<style lang="scss" scoped>

View File

@@ -74,6 +74,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { workcaseChatAPI } from '@/api/workcase'
import { MEET_URL } from '@/config'
const statusBarHeight = ref(44)
const navBarHeight = ref(88)
@@ -288,8 +289,24 @@ function copyUrl() {
return
}
// 解析iframeUrl并构建完整的会议链接
let fullMeetingUrl = meetingUrl.value
// 检查是否为微信小程序环境
// #ifdef MP-WEIXIN
// 获取页面参数中的iframeUrl
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
const iframeUrl = currentPage?.options?.iframeUrl || ''
if (iframeUrl) {
// 构建正确的会议链接MEET_URL + iframeUrl
fullMeetingUrl = `${MEET_URL}${iframeUrl}`
}
// #endif
uni.setClipboardData({
data: meetingUrl.value,
data: fullMeetingUrl,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' })
},
@@ -306,10 +323,26 @@ function openInBrowser() {
return
}
// 解析iframeUrl并构建完整的会议链接
let fullMeetingUrl = meetingUrl.value
// 检查是否为微信小程序环境且会议链接不是完整的MEET_URL格式
// #ifdef MP-WEIXIN
// 获取页面参数中的iframeUrl
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
const iframeUrl = currentPage?.options?.iframeUrl || ''
if (iframeUrl) {
// 构建正确的会议链接MEET_URL + iframeUrl
fullMeetingUrl = `${MEET_URL}${iframeUrl}`
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序:先复制链接,然后提示用户通过右上角菜单在浏览器中打开
uni.setClipboardData({
data: meetingUrl.value,
data: fullMeetingUrl,
success: () => {
uni.showModal({
title: '链接已复制',
@@ -329,10 +362,10 @@ function openInBrowser() {
// @ts-ignore
if (typeof plus !== 'undefined') {
// App环境使用plus打开系统浏览器
plus.runtime.openURL(meetingUrl.value)
plus.runtime.openURL(fullMeetingUrl)
} else {
// H5环境新窗口打开
window.open(meetingUrl.value, '_blank')
window.open(fullMeetingUrl, '_blank')
}
// #endif
}

View File

@@ -2,34 +2,53 @@
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: headerPaddingTop + 'px', height: headerTotalHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: headerPaddingTop + 'px', height: headerTotalHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
</view>
<text class="nav-title">我的工单</text>
<view class="nav-capsule"></view>
</view>
<text class="nav-title">我的工单</text>
<view class="nav-capsule"></view>
</view>
<!-- Tab切换 -->
<view class="tabs" :style="{ marginTop: headerTotalHeight + 'px' }">
<view class="tab-item" :class="{ active: activeTab === 'all' }" @tap="changeTab('all')">
<text class="tab-text">全部</text>
<!-- Tab切换 -->
<view class="tabs" :style="{ marginTop: headerTotalHeight + 'px' }">
<view class="tab-item" :class="{ active: activeTab === 'all' }" @tap="changeTab('all')">
<text class="tab-text">全部</text>
</view>
<view class="tab-item" :class="{ active: activeTab === 'pending' }" @tap="changeTab('pending')">
<text class="tab-text">待处理</text>
</view>
<view class="tab-item" :class="{ active: activeTab === 'processing' }" @tap="changeTab('processing')">
<text class="tab-text">处理中</text>
</view>
<view class="tab-item" :class="{ active: activeTab === 'done' }" @tap="changeTab('done')">
<text class="tab-text">已完成</text>
</view>
</view>
<view class="tab-item" :class="{ active: activeTab === 'pending' }" @tap="changeTab('pending')">
<text class="tab-text">待处理</text>
<!-- 用户筛选组件仅非guest用户可见 -->
<view class="filter-section" v-if="!isGuest" :style="{ marginTop: headerTotalHeight + 44 + 'px' }">
<view class="filter-label">选择用户:</view>
<view class="filter-content">
<view class="guest-selector" @tap="showGuestSelector = true">
<text v-if="selectedGuestId" class="selected-guest">
{{ guestsList.find(g => g.userId === selectedGuestId)?.name || '请选择用户' }}
</text>
<text v-else class="placeholder">请选择用户</text>
<view class="selector-arrow">▼</view>
</view>
<view class="btn-group">
<view class="reset-btn" @tap="clearGuestSelect">重置</view>
<view class="query-btn" @tap="handleQuery">查询</view>
</view>
</view>
</view>
<view class="tab-item" :class="{ active: activeTab === 'processing' }" @tap="changeTab('processing')">
<text class="tab-text">处理中</text>
</view>
<view class="tab-item" :class="{ active: activeTab === 'done' }" @tap="changeTab('done')">
<text class="tab-text">已完成</text>
</view>
</view>
<!-- 工单列表 -->
<scroll-view class="list" scroll-y="true">
<!-- 工单列表 -->
<scroll-view class="list" scroll-y="true"
:style="{ marginTop: headerTotalHeight + 44 + (isGuest ? 0 : 44) + 'px' }"
@scrolltolower="handleScrollToLower">
<view class="card" v-for="(item, index) in filteredOrders" :key="index">
<view class="card-header">
<view class="card-title">
@@ -74,128 +93,569 @@
<view class="empty-state" v-if="filteredOrders.length === 0">
<text class="empty-text">暂无工单数据</text>
</view>
<!-- 加载更多提示 -->
<view class="load-more" v-if="loading">
<text class="load-text">加载中...</text>
</view>
<view class="load-more" v-else-if="!hasMore && filteredOrders.length > 0">
<text class="load-text">没有更多数据了</text>
</view>
</scroll-view>
</view>
<!-- 用户选择弹窗 -->
<view class="modal-mask" v-if="showGuestSelector" @tap="showGuestSelector = false"></view>
<view class="modal-content" v-if="showGuestSelector">
<view class="modal-header">
<text class="modal-title">选择用户</text>
</view>
<view class="modal-body">
<view class="guest-item" v-for="guest in guestsList" :key="guest.userId"
@tap="handleGuestSelect(guest)">
<text class="guest-name">{{ guest.name }} {{ guest.phone || '无电话' }}</text>
</view>
<view class="empty-item" v-if="guestsList.length === 0">
<text>暂无可选用户</text>
</view>
</view>
</view>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { TbWorkcaseDTO } from '@/types/workcase'
import { workcaseAPI } from '@/api/workcase/workcase'
import { ref, computed, onMounted } from 'vue'
import type { TbWorkcaseDTO, TbGuestDTO, PageRequest, PageParam } from '@/types'
import { workcaseAPI } from '@/api/workcase/workcase'
import { guestAPI } from '@/api/sys/guest'
// 响应式数据
const headerPaddingTop = ref<number>(44)
const headerTotalHeight = ref<number>(88)
const activeTab = ref<string>('all')
const orders = ref<TbWorkcaseDTO[]>([])
const loading = ref<boolean>(false)
const error = ref<string>('')
// 响应式数据
const headerPaddingTop = ref<number>(44)
const headerTotalHeight = ref<number>(88)
const activeTab = ref<string>('all')
const orders = ref<TbWorkcaseDTO[]>([])
const loading = ref<boolean>(false)
const loadingUsers = ref<boolean>(false)
const error = ref<string>('')
// 计算属性根据tab筛选工单
const filteredOrders = computed(() => {
if (activeTab.value === 'all') {
// 分页相关
const currentPage = ref<number>(1)
const pageSize = ref<number>(10)
const total = ref<number>(0)
const hasMore = ref<boolean>(true)
// 用户筛选相关
const isGuest = ref(true)
const guestsList = ref<TbGuestDTO[]>([])
const selectedGuestId = ref<string>('')
const showGuestSelector = ref<boolean>(false)
// 计算属性直接返回分页数据API已处理筛选
const filteredOrders = computed(() => {
return orders.value
}
return orders.value.filter(o => o.status === activeTab.value)
})
})
// 生命周期
onMounted(() => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 44
// #ifdef MP-WEIXIN
try {
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
headerPaddingTop.value = menuButtonInfo.top
headerTotalHeight.value = menuButtonInfo.bottom + 8
} catch (e) {
// 生命周期
onMounted(() => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 44
// #ifdef MP-WEIXIN
try {
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
headerPaddingTop.value = menuButtonInfo.top
headerTotalHeight.value = menuButtonInfo.bottom + 8
} catch (e) {
headerPaddingTop.value = statusBarHeight
headerTotalHeight.value = statusBarHeight + 44
}
// #endif
// #ifndef MP-WEIXIN
headerPaddingTop.value = statusBarHeight
headerTotalHeight.value = statusBarHeight + 44
}
// #endif
// #ifndef MP-WEIXIN
headerPaddingTop.value = statusBarHeight
headerTotalHeight.value = statusBarHeight + 44
// #endif
// 调用API获取工单列表
loadWorkcaseList()
})
// #endif
// 加载工单列表
async function loadWorkcaseList() {
loading.value = true
error.value = ''
try {
const filter: TbWorkcaseDTO = {}
if (activeTab.value !== 'all') {
filter.status = activeTab.value as TbWorkcaseDTO['status']
// 检查用户类型
checkUserType()
// 调用API获取工单列表
loadWorkcaseList()
})
// 检查用户类型
function checkUserType() {
try {
const userInfo = uni.getStorageSync('userInfo')
const parsedUserInfo = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
isGuest.value = parsedUserInfo.status === 'guest'
// 如果是非guest用户加载可选人员列表
if (!isGuest.value) {
loadGuestsList()
}
} catch (error) {
console.error('检查用户类型失败:', error)
isGuest.value = true
}
const res = await workcaseAPI.getWorkcaseList(filter)
if (res.success && res.dataList) {
orders.value = res.dataList || []
} else {
error.value = res.message || '加载失败'
}
// 加载可选人员列表
async function loadGuestsList() {
loadingUsers.value = true
try {
const res = await guestAPI.listGuest()
if (res.success && res.dataList) {
guestsList.value = res.dataList
}
} catch (error) {
console.error('加载可选人员列表失败:', error)
} finally {
loadingUsers.value = false
}
}
// 加载工单列表 - 滚动分页查询
async function loadWorkcaseList(isLoadMore = false) {
// 如果正在加载或没有更多数据,直接返回
if (loading.value || (!isLoadMore && !hasMore.value)) return
loading.value = true
error.value = ''
try {
const filter: TbWorkcaseDTO = {}
if (activeTab.value !== 'all') {
filter.status = activeTab.value as TbWorkcaseDTO['status']
}
// 如果是非guest用户且选择了特定用户添加userId筛选
if (!isGuest.value && selectedGuestId.value) {
filter.userId = selectedGuestId.value
}
// 计算当前页码
const currentPageNum = isLoadMore ? currentPage.value + 1 : 1
const pageParam: PageParam = {
page: currentPageNum,
pageSize: pageSize.value
}
const pageRequest: PageRequest<TbWorkcaseDTO> = {
filter,
pageParam
}
const res = await workcaseAPI.getWorkcasePage(pageRequest)
if (res.success) {
const newOrders = res.dataList || res.pageDomain?.dataList || []
total.value = res.pageDomain?.pageParam?.total || 0
// 根据是否加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
orders.value = [...orders.value, ...newOrders]
currentPage.value++
} else {
// 刷新:替换数据
orders.value = newOrders
currentPage.value = 1
}
// 判断是否还有更多数据
hasMore.value = orders.value.length < total.value
} else {
error.value = res.message || '加载失败'
uni.showToast({
title: res.message || '加载失败',
icon: 'error'
})
// 如果是加载更多失败保持hasMore不变
if (!isLoadMore) {
hasMore.value = false
}
}
} catch (e) {
error.value = '网络错误,请稍后重试'
uni.showToast({
title: res.message || '加载失败',
title: '网络错误,请稍后重试',
icon: 'error'
})
// 如果是加载更多失败保持hasMore不变
if (!isLoadMore) {
hasMore.value = false
}
} finally {
loading.value = false
}
} catch (e) {
error.value = '网络错误,请稍后重试'
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'error'
}
// 处理滚动到底部事件
function handleScrollToLower() {
if (hasMore.value && !loading.value) {
loadWorkcaseList(true)
}
}
// 处理用户选择
function handleGuestSelect(guest : TbGuestDTO) {
selectedGuestId.value = guest.userId || ''
showGuestSelector.value = false
// 重置分页状态
hasMore.value = true
loadWorkcaseList() // 根据选中的用户重新加载工单列表
}
// 清除用户选择
function clearGuestSelect() {
selectedGuestId.value = ''
// 重置分页状态
hasMore.value = true
loadWorkcaseList() // 重新加载所有工单列表
}
// 查询工单列表
function handleQuery() {
// 重置分页状态
hasMore.value = true
loadWorkcaseList() // 根据当前筛选条件重新加载工单列表
}
// 切换Tab
function changeTab(tab : string) {
activeTab.value = tab
// 重置分页状态
hasMore.value = true
loadWorkcaseList()
}
// 获取状态样式类
function getStatusClass(status ?: string) : string {
switch (status) {
case 'pending': return 'status-pending'
case 'processing': return 'status-processing'
case 'done': return 'status-done'
default: return 'status-pending'
}
}
// 获取状态文本
function getStatusText(status ?: string) : string {
switch (status) {
case 'pending': return '待处理'
case 'processing': return '处理中'
case 'done': return '已完成'
default: return '未知'
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 跳转到工单详情
function goDetail(workcaseId ?: string) {
if (!workcaseId) return
uni.navigateTo({
url: `/pages/workcase/workcaseDetail/workcaseDetail?workcaseId=${workcaseId}`
})
} finally {
loading.value = false
}
}
// 切换Tab
function changeTab(tab: string) {
activeTab.value = tab
loadWorkcaseList()
}
// 获取状态样式类
function getStatusClass(status?: string): string {
switch (status) {
case 'pending': return 'status-pending'
case 'processing': return 'status-processing'
case 'done': return 'status-done'
default: return 'status-pending'
}
}
// 获取状态文本
function getStatusText(status?: string): string {
switch (status) {
case 'pending': return '待处理'
case 'processing': return '处理中'
case 'done': return '已完成'
default: return '未知'
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 跳转到工单详情
function goDetail(workcaseId?: string) {
if (!workcaseId) return
uni.navigateTo({
url: `/pages/workcase/workcaseDetail/workcaseDetail?workcaseId=${workcaseId}`
})
}
</script>
<style lang="scss" scoped>
@import "./workcaseList.scss";
@import "./workcaseList.scss";
// 用户筛选组件样式
.filter-section {
position: fixed;
left: 0;
right: 0;
height: 44px;
background: #fff;
border-bottom: 1px solid #eee;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
z-index: 10;
}
.filter-label {
font-size: 14px;
color: #333;
margin-right: 8px;
}
.filter-content {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.guest-selector {
flex: 1;
flex-direction: row;
height: 32px;
border: 1px solid #ddd;
border-radius: 16px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f9f9f9;
cursor: pointer;
transition: all 0.3s ease;
}
.guest-selector:active {
background: #e9e9e9;
border-color: #bbb;
}
.selected-guest {
font-size: 14px;
color: #333;
}
.placeholder {
font-size: 14px;
color: #999;
}
.selector-arrow {
font-size: 12px;
color: #666;
transition: transform 0.3s ease;
}
.filter-section:active .selector-arrow {
transform: rotate(180deg);
}
/* 按钮组样式 */
.btn-group {
display: flex;
flex-direction: row;
gap: 8px;
}
.reset-btn,
.query-btn {
height: 32px;
padding: 0 16px;
border-radius: 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.reset-btn {
background: #f5f5f5;
border: 1px solid #ddd;
color: #666;
}
.reset-btn:active {
background: #e9e9e9;
border-color: #bbb;
}
.query-btn {
background: #1989fa;
border: 1px solid #1989fa;
color: #fff;
}
.query-btn:active {
background: #0066cc;
border-color: #0066cc;
}
// 弹窗样式
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 400px;
background: #fff;
border-radius: 20px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.25);
z-index: 1001;
animation: slideUp 0.3s ease;
overflow: hidden;
}
/* 弹窗动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.modal-close {
font-size: 24px;
color: #999;
cursor: pointer;
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.modal-close:active {
background: #f0f0f0;
color: #666;
transform: rotate(90deg);
}
.modal-body {
max-height: 350px;
overflow-y: auto;
padding: 0;
}
/* 滚动条样式 */
.modal-body::-webkit-scrollbar {
width: 6px;
}
.modal-body::-webkit-scrollbar-track {
background: #f1f1f1;
}
.modal-body::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.modal-body::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.guest-item {
display: flex;
flex-direction: column;
padding: 16px 20px;
cursor: pointer;
transition: all 0.3s ease;
border-bottom: 1px solid #f5f5f5;
}
.guest-item:last-child {
border-bottom: none;
}
.guest-item:active {
background-color: #f8f8f8;
transform: translateX(5px);
}
.guest-item:active .guest-name {
color: #1989fa;
}
.guest-name {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
transition: color 0.3s ease;
}
.guest-phone {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
.guest-phone::before {
content: "📞";
font-size: 12px;
}
.empty-item {
padding: 30px 20px;
text-align: center;
color: #999;
font-size: 14px;
background: #fafafa;
}
.empty-item::before {
content: "👤";
font-size: 48px;
display: block;
margin-bottom: 12px;
opacity: 0.5;
}
/* 加载更多样式 */
.load-more {
padding: 20px;
text-align: center;
background: #fff;
border-top: 1px solid #eee;
margin-top: 0;
}
.load-text {
font-size: 14px;
color: #999;
}
</style>