Compare commits

...

3 Commits

Author SHA1 Message Date
7463c6ed88 Merge branch 'master' into docker 2026-01-22 11:36:43 +08:00
12d9294f3d 小程序优化 2026-01-22 11:36:26 +08:00
8ab6107f25 1轮修复 2026-01-20 16:17:39 +08:00
23 changed files with 2699 additions and 647 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'
// 从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 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-col>
<el-col :span="12">
<el-form-item label="联系电话" required>
<el-input v-model="formData.phone" placeholder="请输入联系电话" />
<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-col>
</el-row>
<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:TbGuestDTO) => 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

@@ -3,5 +3,8 @@ export const AGENT_ID = '17678420499370001'
// 根据宝塔nginx配置/urban-lifeline/ 会代理到后端网关
export const BASE_URL = 'https://demo-urbanlifeline.tensorgrove.com/urban-lifeline'
// 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="showUploadOptions">
<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) {
@@ -653,45 +697,27 @@ async function handleJoinMeeting(meetingId: string) {
const meetingName = meetingData.meetingName || '视频会议'
console.log('[handleJoinMeeting] 获取到会议页面URL:', meetingPageUrl, '会议名称:', meetingName)
// 使用API返回的完整jitsiIframeUrl作为会议链接
let fullMeetingUrl = meetingData.jitsiIframeUrl || ''
// 如果没有jitsiIframeUrl再使用iframeUrl构建
if (!fullMeetingUrl && meetingData.iframeUrl) {
// 小程序环境直接使用固定的HTTPS域名
const protocol = 'https:'
const host = 'org.xyzh.yslg'
// 如果meetingPageUrl不包含/workcase需要加上
const fullPath = meetingPageUrl.startsWith('/workcase')
? meetingPageUrl
: '/workcase' + meetingPageUrl
const fullPath = meetingData.iframeUrl.startsWith('/workcase')
? meetingData.iframeUrl
: '/workcase' + meetingData.iframeUrl
// 附加roomId参数用于离开会议后返回聊天室
const fullMeetingUrl = `${protocol}//${host}${fullPath}&roomId=${roomId.value}`
fullMeetingUrl = `${protocol}//${host}${fullPath}&roomId=${roomId.value}`
}
console.log('[handleJoinMeeting] 完整会议URL:', fullMeetingUrl)
// 小程序环境:显示提示,引导用户复制链接在浏览器打开
uni.showModal({
title: '视频会议',
content: '微信小程序暂不支持视频会议,请复制链接在浏览器中打开',
confirmText: '复制链接',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 复制链接到剪贴板
uni.setClipboardData({
data: fullMeetingUrl,
success: () => {
uni.showToast({
title: '链接已复制,请在浏览器中打开',
icon: 'none',
duration: 3000
})
},
fail: () => {
uni.showToast({
title: '复制失败,请手动复制',
icon: 'none'
})
}
})
}
}
// 跳转到会议页面
uni.navigateTo({
url: `/pages/meeting/Meeting?meetingId=${meetingId}&meetingName=${encodeURIComponent(meetingName)}&jitsiIframeUrl=${encodeURIComponent(fullMeetingUrl)}`
})
} else {
console.error('[handleJoinMeeting] 加入会议失败, isSuccess:', isSuccess, 'data:', meetingData)
@@ -734,6 +760,238 @@ async function handleCommentSubmit(rating: number) {
}
}
// 文件操作相关函数
// 显示上传选项
function showUploadOptions() {
uni.showActionSheet({
itemList: ['拍照', '从相册选择', '选择文件'],
success: (res) => {
switch (res.tapIndex) {
case 0:
// 拍照
chooseImageFromCamera()
break
case 1:
// 从相册选择
chooseImageFromAlbum()
break
case 2:
// 选择文件
chooseFile()
break
}
}
})
}
// 拍照
function chooseImageFromCamera() {
uni.chooseImage({
count: 9,
sourceType: ['camera'],
success: (res) => {
if (res.tempFiles && res.tempFiles.length > 0) {
// 添加到选中文件列表
selectedFiles.value = [...selectedFiles.value, ...res.tempFiles]
}
},
fail: (err) => {
console.error('拍照失败:', err)
uni.showToast({ title: '拍照失败', icon: 'none' })
}
})
}
// 从相册选择
function chooseImageFromAlbum() {
uni.chooseImage({
count: 9,
sourceType: ['album'],
success: (res) => {
if (res.tempFiles && res.tempFiles.length > 0) {
// 添加到选中文件列表
selectedFiles.value = [...selectedFiles.value, ...res.tempFiles]
}
},
fail: (err) => {
console.error('从相册选择失败:', err)
uni.showToast({ title: '选择图片失败', icon: 'none' })
}
})
}
// 选择文件
function chooseFile() {
// #ifdef MP-WEIXIN
// 微信小程序使用 chooseMessageFile
uni.chooseMessageFile({
count: 9,
type: 'file',
extension: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'jpg', 'jpeg', 'png', 'gif'],
success: (res : any) => {
console.log('选择文件成功:', res)
if (res.tempFiles && res.tempFiles.length > 0) {
// 添加到选中文件列表
selectedFiles.value = [...selectedFiles.value, ...res.tempFiles]
}
},
fail: (err : any) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
icon: 'none'
})
}
})
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境
// @ts-ignore
if (typeof uni.chooseFile === 'function') {
// @ts-ignore
uni.chooseFile({
count: 9,
extension: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.jpg', '.jpeg', '.png', '.gif'],
success: (res : any) => {
console.log('选择文件成功:', res)
if (res.tempFiles && res.tempFiles.length > 0) {
// 添加到选中文件列表
selectedFiles.value = [...selectedFiles.value, ...res.tempFiles]
}
},
fail: (err : any) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
icon: 'none'
})
}
})
} else {
uni.showToast({
title: '当前环境不支持文件选择',
icon: 'none'
})
}
// #endif
}
// 删除选中的文件
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 +1080,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

@@ -4,7 +4,7 @@
<!-- #endif -->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: navPaddingTop + 'px', height: navHeight + 'px' }">
<view class="nav" :style="{ paddingTop: navPaddingTop + 'px', height: navTotalHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
</view>
@@ -12,8 +12,28 @@
<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="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: navHeight + 'px' }">
<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>
@@ -42,7 +62,32 @@
<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>
</view>
<!-- #ifdef APP -->
</scroll-view>
@@ -50,25 +95,48 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { workcaseChatAPI } from '@/api'
import type { ChatRoomVO, TbChatRoomDTO, PageRequest, ChatRoomMessageVO } from '@/types'
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 navTotalHeight = ref<number>(44)
const capsuleHeight = ref<number>(32)
// 加载状态
const loading = ref<boolean>(false)
const loadingUsers = ref<boolean>(false)
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([])
// 分页相关
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(() => {
onMounted(async () => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 20
@@ -88,7 +156,16 @@ onMounted(() => {
navHeight.value = navPaddingTop.value + 44
// #endif
// 设置导航栏总高度
navTotalHeight.value = navHeight.value
// 检查用户类型
checkUserType()
// 加载聊天室列表
loadChatRooms()
// 初始化WebSocket
initWebSocket()
})
@@ -97,26 +174,79 @@ onUnmounted(() => {
disconnectWebSocket()
})
// 检查用户类型
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
}
}
// 加载可选人员列表
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() {
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: userId // 查询当前用户的聊天室
guestId: isGuest.value ? userId : selectedGuestId.value // 如果是guest用户查询自己的聊天室否则根据选中的用户ID查询
},
pageParam: {
pageNumber: 1,
pageSize: 50
page: currentPageNum,
pageSize: pageSize.value
}
}
const res = await workcaseChatAPI.getChatRoomPage(pageRequest)
if (res.success && res.pageDomain?.dataList) {
chatRooms.value = 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)
@@ -124,11 +254,46 @@ async function loadChatRooms() {
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 ''
@@ -291,4 +456,291 @@ async function handleListUpdate(message: ChatRoomMessageVO) {
<style lang="scss" scoped>
@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;
}
@@ -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;
@@ -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;
background-color: #fff; // 增加背景色,避免穿透
}
// 快捷按钮横向滚动
// 快捷按钮横向滚动 - 修复横向滚动问题
.quick-scroll {
white-space: nowrap;
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;
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;
/* 确保按钮不换行 */
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,23 +88,22 @@
<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">
<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>
<view class="quick-btn has-icon" @tap="showCreator">
<view class="quick-btn has-icon" @tap="showOperationSelect('workcase')">
<text class="quick-icon">⊕</text>
<text class="quick-text">创建工单</text>
</view>
@@ -113,6 +114,12 @@
<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>
@@ -140,14 +147,14 @@
<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">
<view class="send-btn" :class="{ active: inputText.trim() || uploadedFiles.length > 0 }"
@tap="sendMessage">
<text class="send-icon">➤</text>
</view>
</view>
</view>
</view>
</view>
<!-- 设备代码输入弹窗 -->
<view class="device-code-modal" v-if="showDeviceCodeDialog">
@@ -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>
@@ -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 {
@@ -690,7 +720,8 @@
onError: (error) => {
console.error('SSE错误:', error)
isTyping.value = false
messages.value[messageIndex].content = error
// 只返回友好的错误信息,而不是原始错误对象
messages.value[messageIndex].content = '抱歉AI服务暂时不可用请稍后重试。'
},
onComplete: () => {
isTyping.value = false
@@ -891,6 +922,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() {
// 检查设备代码
@@ -1191,7 +1263,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)
@@ -283,13 +284,27 @@ function handleWebViewError(e: any) {
// 复制会议链接
function copyUrl() {
if (!meetingUrl.value) {
// 获取页面参数
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
// 优先检查页面参数中是否有jitsiIframeUrl
let fullMeetingUrl = ''
// 检查是否直接传递了jitsiIframeUrl参数
const jitsiIframeUrl = currentPage?.options?.jitsiIframeUrl || ''
if (jitsiIframeUrl) {
fullMeetingUrl = jitsiIframeUrl
} else if (meetingUrl.value) {
// 兼容老版本使用meetingUrl
fullMeetingUrl = meetingUrl.value
} else {
uni.showToast({ title: '会议链接为空', icon: 'none' })
return
}
uni.setClipboardData({
data: meetingUrl.value,
data: fullMeetingUrl,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' })
},
@@ -301,7 +316,21 @@ function copyUrl() {
// 在浏览器中打开
function openInBrowser() {
if (!meetingUrl.value) {
// 获取页面参数
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
// 优先检查页面参数中是否有jitsiIframeUrl
let fullMeetingUrl = ''
// 检查是否直接传递了jitsiIframeUrl参数
const jitsiIframeUrl = currentPage?.options?.jitsiIframeUrl || ''
if (jitsiIframeUrl) {
fullMeetingUrl = jitsiIframeUrl
} else if (meetingUrl.value) {
// 兼容老版本使用meetingUrl
fullMeetingUrl = meetingUrl.value
} else {
uni.showToast({ title: '会议链接为空', icon: 'none' })
return
}
@@ -309,7 +338,7 @@ function openInBrowser() {
// #ifdef MP-WEIXIN
// 微信小程序:先复制链接,然后提示用户通过右上角菜单在浏览器中打开
uni.setClipboardData({
data: meetingUrl.value,
data: fullMeetingUrl,
success: () => {
uni.showModal({
title: '链接已复制',
@@ -329,10 +358,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

@@ -27,9 +27,28 @@
<text class="tab-text">已完成</text>
</view>
</view>
<!-- 用户筛选组件仅非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>
<!-- 工单列表 -->
<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,7 +93,32 @@
<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 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>
@@ -83,8 +127,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { TbWorkcaseDTO } from '@/types/workcase'
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)
@@ -92,14 +137,24 @@ 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 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(() => {
if (activeTab.value === 'all') {
return orders.value
}
return orders.value.filter(o => o.status === activeTab.value)
})
// 生命周期
@@ -122,12 +177,50 @@ onMounted(() => {
headerTotalHeight.value = statusBarHeight + 44
// #endif
// 检查用户类型
checkUserType()
// 调用API获取工单列表
loadWorkcaseList()
})
// 加载工单列表
async function 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
}
}
// 加载可选人员列表
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 {
@@ -135,15 +228,53 @@ async function loadWorkcaseList() {
if (activeTab.value !== 'all') {
filter.status = activeTab.value as TbWorkcaseDTO['status']
}
const res = await workcaseAPI.getWorkcaseList(filter)
if (res.success && res.dataList) {
orders.value = res.dataList || []
// 如果是非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 = '网络错误,请稍后重试'
@@ -151,14 +282,51 @@ async function loadWorkcaseList() {
title: '网络错误,请稍后重试',
icon: 'error'
})
// 如果是加载更多失败保持hasMore不变
if (!isLoadMore) {
hasMore.value = false
}
} finally {
loading.value = false
}
}
// 处理滚动到底部事件
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()
}
@@ -198,4 +366,296 @@ function goDetail(workcaseId?: string) {
<style lang="scss" scoped>
@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>