overview统计

This commit is contained in:
2026-01-01 16:19:55 +08:00
parent eb15706ccc
commit b53faca120
17 changed files with 425 additions and 40 deletions

View File

@@ -72,6 +72,14 @@ public interface ChatRoomService {
*/ */
ResultDomain<ChatRoomVO> getChatRoomPage(PageRequest<TbChatRoomDTO> pageRequest, String userId); ResultDomain<ChatRoomVO> getChatRoomPage(PageRequest<TbChatRoomDTO> pageRequest, String userId);
/**
* @description 统计聊天室数量
* @param filter 筛选条件
* @author yslg
* @since 2026-01-01
*/
ResultDomain<Long> countChatRooms(TbChatRoomDTO filter);
// ========================= 聊天室成员管理 ========================== // ========================= 聊天室成员管理 ==========================
/** /**

View File

@@ -0,0 +1,29 @@
package org.xyzh.api.workcase.service;
import org.xyzh.api.workcase.dto.TbWordCloudDTO;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageRequest;
/**
* @description 词云服务接口
* @filename WordCloudService.java
* @author yslg
* @copyright xyzh
* @since 2025-12-19
*/
public interface WordCloudService {
/**
* 查询词云列表
* @param filter 筛选条件
* @return 词云列表
*/
ResultDomain<TbWordCloudDTO> getWordCloudList(TbWordCloudDTO filter);
/**
* 分页查询词云
* @param pageRequest 分页请求
* @return 词云分页数据
*/
ResultDomain<TbWordCloudDTO> getWordCloudPage(PageRequest<TbWordCloudDTO> pageRequest);
}

View File

@@ -55,6 +55,22 @@ public interface WorkcaseService {
*/ */
ResultDomain<TbWorkcaseDTO> getWorkcasePage(PageRequest<TbWorkcaseDTO> pageRequest); ResultDomain<TbWorkcaseDTO> getWorkcasePage(PageRequest<TbWorkcaseDTO> pageRequest);
/**
* @description 统计各个类型的工单数量
* @param filter
* @author yslg
* @since 2026-01-01
*/
ResultDomain<TbWorkcaseDTO> countWorkcasesByType(TbWorkcaseDTO filter);
/**
* @description 统计工单数量
* @param filter
* @author yslg
* @since 2026-01-01
*/
ResultDomain<Long> countWorkcases(TbWorkcaseDTO filter);
/** /**
* @description 获取工单详情 * @description 获取工单详情
* @param workcaseId * @param workcaseId

View File

@@ -50,6 +50,9 @@ public class BaseDTO implements Serializable {
@Schema(description = "数量限制") @Schema(description = "数量限制")
private Integer limit; private Integer limit;
@Schema(description = "统计数量")
private Integer count;
@Schema(description = "开始时间") @Schema(description = "开始时间")
private Date startTime; private Date startTime;

View File

@@ -162,7 +162,7 @@ public class WorkcaseChatController {
if (!vr.isValid()) { if (!vr.isValid()) {
return ResultDomain.failure(vr.getAllErrors()); return ResultDomain.failure(vr.getAllErrors());
} }
LoginDomain loginDomain = LoginUtil.getCurrentLogin(); LoginDomain loginDomain = LoginUtil.getCurrentLogin();
String userId = loginDomain.getUser().getUserId(); String userId = loginDomain.getUser().getUserId();
if("guest".equals(loginDomain.getUser().getStatus())){ if("guest".equals(loginDomain.getUser().getStatus())){
@@ -171,6 +171,13 @@ public class WorkcaseChatController {
return chatRoomService.getChatRoomPage(pageRequest, userId); return chatRoomService.getChatRoomPage(pageRequest, userId);
} }
@Operation(summary = "统计聊天室数量")
@PreAuthorize("hasAuthority('workcase:room:view')")
@PostMapping("/room/count")
public ResultDomain<Long> countChatRooms(@RequestBody TbChatRoomDTO filter) {
return chatRoomService.countChatRooms(filter);
}
// ========================= ChatRoom成员管理 ========================= // ========================= ChatRoom成员管理 =========================
@Operation(summary = "添加聊天室成员") @Operation(summary = "添加聊天室成员")

View File

@@ -19,6 +19,7 @@ import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.LoginDomain; import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageRequest; import org.xyzh.common.core.page.PageRequest;
import org.xyzh.common.utils.validation.ValidationParam;
import org.xyzh.common.utils.validation.ValidationResult; import org.xyzh.common.utils.validation.ValidationResult;
import org.xyzh.common.utils.validation.ValidationUtils; import org.xyzh.common.utils.validation.ValidationUtils;
@@ -26,6 +27,8 @@ import com.alibaba.fastjson2.JSONObject;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
/** /**
@@ -116,6 +119,49 @@ public class WorkcaseController {
return workcaseService.getWorkcasePage(pageRequest); return workcaseService.getWorkcasePage(pageRequest);
} }
@Operation(summary = "查询工单问题统计")
@PreAuthorize("hasAuthority('workcase:ticket:view')")
@PostMapping("/category/count")
public ResultDomain<TbWorkcaseDTO> countWorkcasesByType(@RequestBody TbWorkcaseDTO workcase) {
ValidationResult vr = ValidationUtils.validate(workcase, Arrays.asList(
ValidationParam.builder()
.fieldName("startTime")
.fieldLabel("统计开始时间")
.required()
.build(),
// 校验结束时间不为空
ValidationParam.builder()
.fieldName("endTime")
.fieldLabel("统计结束时间")
.required()
.build(),
// 校验开始时间小于结束时间(使用 fieldCompare 比较两个字段)
ValidationUtils.fieldCompare(
"startTime",
"endTime",
"统计时间",
(startTime, endTime) -> {
if (startTime instanceof Date && endTime instanceof Date) {
return ((Date) startTime).before((Date) endTime);
}
return true;
},
"统计开始时间不能晚于结束时间"
)
));
if (!vr.isValid()) {
return ResultDomain.failure(vr.getAllErrors());
}
return workcaseService.countWorkcasesByType(workcase);
}
@Operation(summary = "统计工单数量")
@PreAuthorize("hasAuthority('workcase:ticket:view')")
@PostMapping("/count")
public ResultDomain<Long> countWorkcases(@RequestBody TbWorkcaseDTO workcase) {
return workcaseService.countWorkcases(workcase);
}
// ========================= CRM同步接口 ========================= // ========================= CRM同步接口 =========================
@Operation(summary = "同步工单到CRM") @Operation(summary = "同步工单到CRM")

View File

@@ -52,4 +52,6 @@ public interface TbWorkcaseMapper {
*/ */
long countWorkcases(@Param("filter") TbWorkcaseDTO filter); long countWorkcases(@Param("filter") TbWorkcaseDTO filter);
List<TbWorkcaseDTO> countWorkcasesByType(@Param("filter") TbWorkcaseDTO filter);
} }

View File

@@ -219,6 +219,12 @@ public class ChatRoomServiceImpl implements ChatRoomService {
return ResultDomain.success("查询聊天室成功", pageDomain); return ResultDomain.success("查询聊天室成功", pageDomain);
} }
@Override
public ResultDomain<Long> countChatRooms(TbChatRoomDTO filter) {
long count = chatRoomMapper.countChatRooms(filter);
return ResultDomain.success("查询成功", count);
}
// ========================= 聊天室成员管理 ========================== // ========================= 聊天室成员管理 ==========================
@Override @Override

View File

@@ -227,6 +227,18 @@ public class WorkcaseServiceImpl implements WorkcaseService {
return ResultDomain.success("查询成功", pageDomain); return ResultDomain.success("查询成功", pageDomain);
} }
@Override
public ResultDomain<TbWorkcaseDTO> countWorkcasesByType(TbWorkcaseDTO filter) {
List<TbWorkcaseDTO> workcases = workcaseMapper.countWorkcasesByType(filter);
return ResultDomain.success("查询成功", workcases);
}
@Override
public ResultDomain<Long> countWorkcases(TbWorkcaseDTO filter) {
long count = workcaseMapper.countWorkcases(filter);
return ResultDomain.success("查询成功", count);
}
// ====================== 同步到CRM和接收 =================== // ====================== 同步到CRM和接收 ===================
@Override @Override

View File

@@ -158,6 +158,9 @@
<if test="filter.status != null and filter.status != ''">AND status = #{filter.status}</if> <if test="filter.status != null and filter.status != ''">AND status = #{filter.status}</if>
<if test="filter.guestId != null and filter.guestId != ''">AND guest_id = #{filter.guestId}</if> <if test="filter.guestId != null and filter.guestId != ''">AND guest_id = #{filter.guestId}</if>
<if test="filter.guestName != null and filter.guestName != ''">AND guest_name LIKE CONCAT('%', #{filter.guestName}, '%')</if> <if test="filter.guestName != null and filter.guestName != ''">AND guest_name LIKE CONCAT('%', #{filter.guestName}, '%')</if>
<if test="filter.startTime != null and filter.endTime != null">
AND create_time BETWEEN #{filter.startTime} AND #{filter.endTime}
</if>
AND deleted = false AND deleted = false
</where> </where>
</select> </select>

View File

@@ -83,6 +83,9 @@
</if> </if>
</where> </where>
ORDER BY frequency DESC, create_time DESC ORDER BY frequency DESC, create_time DESC
<if test="filter.limit != null and filter.limit > 0">
LIMIT #{filter.limit}
</if>
</select> </select>
<select id="selectWordCloudPage" resultMap="BaseResultMap"> <select id="selectWordCloudPage" resultMap="BaseResultMap">

View File

@@ -207,4 +207,13 @@
</where> </where>
</select> </select>
<select id="countWorkcasesByType" resultType="org.xyzh.api.workcase.dto.TbWorkcaseDTO">
SELECT type, COUNT(*) as count
FROM workcase.tb_workcase
WHERE create_time BETWEEN #{filter.startTime} AND #{filter.endTime}
AND deleted = false
GROUP BY type
ORDER BY count DESC
</select>
</mapper> </mapper>

View File

@@ -181,5 +181,25 @@ export const workcaseAPI = {
async getWorkcaseDevicePage(pageRequest: PageRequest<TbWorkcaseDeviceDTO>): Promise<ResultDomain<TbWorkcaseDeviceDTO>> { async getWorkcaseDevicePage(pageRequest: PageRequest<TbWorkcaseDeviceDTO>): Promise<ResultDomain<TbWorkcaseDeviceDTO>> {
const response = await api.post<TbWorkcaseDeviceDTO>(`${this.baseUrl}/device/page`, pageRequest) const response = await api.post<TbWorkcaseDeviceDTO>(`${this.baseUrl}/device/page`, pageRequest)
return response.data return response.data
},
// ========================= 工单统计 =========================
/**
* 查询工单问题分类统计
* @param filter 筛选条件startTime, endTime
*/
async countWorkcasesByType(filter: TbWorkcaseDTO): Promise<ResultDomain<TbWorkcaseDTO>> {
const response = await api.post<TbWorkcaseDTO>(`${this.baseUrl}/category/count`, filter)
return response.data
},
/**
* 统计工单数量
* @param filter 筛选条件status等
*/
async countWorkcases(filter: TbWorkcaseDTO): Promise<ResultDomain<number>> {
const response = await api.post<number>(`${this.baseUrl}/count`, filter)
return response.data
} }
} }

View File

@@ -312,5 +312,16 @@ export const workcaseChatAPI = {
Object.keys(body).length > 0 ? body : {} Object.keys(body).length > 0 ? body : {}
) )
return response.data return response.data
},
// ====================== 统计接口 ======================
/**
* 统计聊天室数量
* @param filter 筛选条件startTime, endTime, status等
*/
async countChatRooms(filter: TbChatRoomDTO): Promise<ResultDomain<number>> {
const response = await api.post<number>(`${this.baseUrl}/room/count`, filter)
return response.data
} }
} }

View File

@@ -139,6 +139,7 @@ $brand-color-hover: #004488;
.product-cloud { .product-cloud {
display: flex; display: flex;
justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px 12px; gap: 8px 12px;
padding: 8px 0; padding: 8px 0;

View File

@@ -1 +1,31 @@
// OverviewView 样式占位符 // OverviewView 样式
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #909399;
p {
margin-top: 16px;
font-size: 14px;
color: #909399;
}
}
.question-stats {
min-height: 200px;
}
.product-cloud {
min-height: 150px;
}
// 趋势样式
.stat-trend {
&.down {
color: #f56c6c !important;
}
}

View File

@@ -8,11 +8,12 @@
<el-icon><ChatDotRound /></el-icon> <el-icon><ChatDotRound /></el-icon>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<div class="stat-value">1,258</div> <div class="stat-value">{{ dashboardData.consultCount }}</div>
<div class="stat-label">咨询次数</div> <div class="stat-label">咨询次数</div>
<div class="stat-trend up"> <div class="stat-trend" :class="dashboardData.consultTrend >= 0 ? 'up' : 'down'">
<el-icon><Top /></el-icon> <el-icon v-if="dashboardData.consultTrend >= 0"><Top /></el-icon>
<span>较昨日 +12.5%</span> <el-icon v-else><Top style="transform: rotate(180deg);" /></el-icon>
<span>较昨日 {{ dashboardData.consultTrend >= 0 ? '+' : '' }}{{ dashboardData.consultTrend }}%</span>
</div> </div>
</div> </div>
</div> </div>
@@ -60,15 +61,21 @@
</div> </div>
</template> </template>
<div class="question-stats"> <div class="question-stats">
<div v-for="item in questionCategories" :key="item.name" class="question-stat-item clickable"> <div v-if="questionCategories.length > 0">
<div class="stat-bar-header"> <div v-for="item in questionCategories" :key="item.name" class="question-stat-item clickable">
<span class="stat-name">{{ item.name }}</span> <div class="stat-bar-header">
<span class="stat-count">{{ item.count }} </span> <span class="stat-name">{{ item.name }}</span>
</div> <span class="stat-count">{{ item.count }} </span>
<div class="stat-bar"> </div>
<div class="stat-bar-fill" :style="{ width: item.percent + '%', background: item.color }"></div> <div class="stat-bar">
<div class="stat-bar-fill" :style="{ width: item.percent + '%', background: item.color }"></div>
</div>
</div> </div>
</div> </div>
<div v-else class="empty-state">
<el-icon :size="48" style="color: #dcdfe6;"><Document /></el-icon>
<p>暂无数据</p>
</div>
</div> </div>
</el-card> </el-card>
@@ -83,10 +90,16 @@
</div> </div>
</template> </template>
<div class="product-cloud"> <div class="product-cloud">
<span v-for="(product, index) in productCloudData" :key="index" class="cloud-tag" <div v-if="productCloudData.length > 0">
:style="{ fontSize: product.size + 'px', color: product.color, opacity: 0.7 + product.weight * 0.3 }"> <span v-for="(product, index) in productCloudData" :key="index" class="cloud-tag"
{{ product.name }} :style="{ fontSize: product.size + 'px', color: product.color, opacity: 0.7 + product.weight * 0.3 }">
</span> {{ product.name }}
</span>
</div>
<div v-else class="empty-state">
<el-icon :size="48" style="color: #dcdfe6;"><Document /></el-icon>
<p>暂无数据</p>
</div>
</div> </div>
</el-card> </el-card>
</div> </div>
@@ -120,49 +133,215 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import AdminLayout from '@/views/admin/AdminLayout.vue' import AdminLayout from '@/views/admin/AdminLayout.vue'
import { MessageCircle as ChatDotRound, Clock, CheckSquare as Select, ArrowUp as Top, ArrowRight as Right, FileText as Document, Ticket as Tickets, Headphones as Service } from 'lucide-vue-next' import { MessageCircle as ChatDotRound, Clock, CheckSquare as Select, ArrowUp as Top, ArrowRight as Right, FileText as Document, Ticket as Tickets, Headphones as Service } from 'lucide-vue-next'
import { workcaseAPI } from '@/api/workcase/workcase'
import { workcaseChatAPI } from '@/api/workcase/workcaseChat'
import type { TbWorkcaseDTO, TbWordCloudDTO, TbChatRoomDTO } from '@/types/workcase'
const router = useRouter()
const questionStatPeriod = ref('today') const questionStatPeriod = ref('today')
// 核心数据统计
const dashboardData = ref({ const dashboardData = ref({
pendingTickets: 24, consultCount: 0, // 咨询次数(昨日聊天室数量)
completedTickets: 856 consultTrend: 0, // 较前日的增减百分比
pendingTickets: 0, // 待处理工单总量
completedTickets: 0 // 已处理工单总量
}) })
const questionCategories = ref([ // 问题分类统计数据
{ name: '设备故障', count: 156, percent: 85, color: '#409eff' }, const questionCategories = ref<Array<{ name: string; count: number; percent: number; color: string }>>([])
{ name: '使用咨询', count: 98, percent: 65, color: '#67c23a' },
{ name: '配件更换', count: 45, percent: 35, color: '#e6a23c' },
{ name: '安装调试', count: 32, percent: 25, color: '#f56c6c' },
{ name: '其他问题', count: 28, percent: 18, color: '#909399' }
])
const productCloudData = ref([ // 词云数据
{ name: 'TH-500GF', size: 24, weight: 0.9, color: '#409eff' }, const productCloudData = ref<Array<{ name: string; size: number; weight: number; color: string }>>([])
{ name: 'TH-300D', size: 20, weight: 0.7, color: '#67c23a' },
{ name: 'TH-800GF', size: 22, weight: 0.8, color: '#e6a23c' }, // 颜色配置
{ name: 'S-200X', size: 18, weight: 0.6, color: '#f56c6c' }, const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
{ name: 'S-150X', size: 16, weight: 0.5, color: '#909399' },
{ name: 'G-100S', size: 19, weight: 0.65, color: '#409eff' }, // 获取昨日和前日的时间范围
{ name: 'G-200S', size: 21, weight: 0.75, color: '#67c23a' } const getYesterdayRange = () => {
]) const today = new Date()
today.setHours(0, 0, 0, 0)
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
const yesterdayEnd = new Date(today)
yesterdayEnd.setMilliseconds(-1)
const dayBeforeYesterday = new Date(yesterday)
dayBeforeYesterday.setDate(yesterday.getDate() - 1)
const dayBeforeYesterdayEnd = new Date(yesterday)
dayBeforeYesterdayEnd.setMilliseconds(-1)
return {
yesterday: { start: yesterday, end: yesterdayEnd },
dayBeforeYesterday: { start: dayBeforeYesterday, end: dayBeforeYesterdayEnd }
}
}
// 根据时间周期计算开始和结束时间
const getTimeRange = (period: string) => {
const now = new Date()
const endTime = now
const startTime = new Date()
if (period === 'today') {
startTime.setHours(0, 0, 0, 0)
} else if (period === 'week') {
startTime.setDate(now.getDate() - 7)
} else if (period === 'month') {
startTime.setMonth(now.getMonth() - 1)
}
return { startTime, endTime }
}
// 加载核心数据统计
const loadDashboardData = async () => {
try {
// 1. 统计昨日和前日的咨询次数使用count接口
const timeRanges = getYesterdayRange()
const yesterdayResult = await workcaseChatAPI.countChatRooms({
startTime: timeRanges.yesterday.start,
endTime: timeRanges.yesterday.end
} as TbChatRoomDTO)
const dayBeforeResult = await workcaseChatAPI.countChatRooms({
startTime: timeRanges.dayBeforeYesterday.start,
endTime: timeRanges.dayBeforeYesterday.end
} as TbChatRoomDTO)
if (yesterdayResult.success && dayBeforeResult.success) {
const yesterdayCount = Number(yesterdayResult.data || 0)
const dayBeforeCount = Number(dayBeforeResult.data || 0)
dashboardData.value.consultCount = yesterdayCount
// 计算增减百分比
if (dayBeforeCount > 0) {
const change = ((yesterdayCount - dayBeforeCount) / dayBeforeCount) * 100
dashboardData.value.consultTrend = Math.round(change * 10) / 10 // 保留1位小数
} else {
dashboardData.value.consultTrend = yesterdayCount > 0 ? 100 : 0
}
}
// 2. 统计待处理工单总量使用count接口
const pendingResult = await workcaseAPI.countWorkcases({
status: 'pending'
} as TbWorkcaseDTO)
if (pendingResult.success && pendingResult.data) {
dashboardData.value.pendingTickets = Number(pendingResult.data || 0)
}
// 3. 统计已处理工单总量使用count接口
const completedResult = await workcaseAPI.countWorkcases({
status: 'done'
} as TbWorkcaseDTO)
if (completedResult.success && completedResult.data) {
dashboardData.value.completedTickets = Number(completedResult.data || 0)
}
} catch (error) {
console.error('加载核心数据统计失败:', error)
}
}
// 加载问题分类统计
const loadQuestionStats = async () => {
try {
const { startTime, endTime } = getTimeRange(questionStatPeriod.value)
const result = await workcaseAPI.countWorkcasesByType({
startTime,
endTime
} as TbWorkcaseDTO)
if (result.success && result.data) {
const data = Array.isArray(result.data) ? result.data : [result.data]
// 计算总数
const total = data.reduce((sum, item) => sum + (item.count || 0), 0)
// 转换数据格式并计算百分比
questionCategories.value = data.map((item, index) => ({
name: item.type || '未分类',
count: Number(item.count || 0),
percent: total > 0 ? Math.round((Number(item.count || 0) / total) * 100) : 0,
color: colors[index % colors.length]
}))
}
} catch (error) {
console.error('加载问题分类统计失败:', error)
}
}
// 加载词云数据
const loadWordCloud = async () => {
try {
const result = await workcaseChatAPI.getWordCloudList({
limit: 10,
category: 'product'
} as TbWordCloudDTO)
if (result.success && result.data) {
const data = Array.isArray(result.data) ? result.data : [result.data]
// 找出最大词频用于归一化
const maxFreq = Math.max(...data.map(item => Number(item.frequency || 0)))
// 转换数据格式
productCloudData.value = data.map((item, index) => {
const freq = Number(item.frequency || 0)
const weight = maxFreq > 0 ? freq / maxFreq : 0
return {
name: item.word || '',
size: 16 + Math.round(weight * 10), // 16-26px
weight,
color: colors[index % colors.length]
}
})
}
} catch (error) {
console.error('加载词云数据失败:', error)
}
}
// 监听时间周期变化
watch(questionStatPeriod, () => {
loadQuestionStats()
})
// 页面加载时获取数据
onMounted(() => {
loadDashboardData()
loadQuestionStats()
loadWordCloud()
})
const goToChat = () => { const goToChat = () => {
console.log('跳转到对话数据') router.push('/admin/customerChat')
} }
const goToWorkcase = (status?: string) => { const goToWorkcase = (status?: string) => {
console.log('跳转到工单管理', status) router.push('/admin/workcase')
} }
const goToKnowledge = () => { const goToKnowledge = () => {
console.log('跳转到知识库管理') router.push('/admin/knowledge')
} }
const goToAgent = () => { const goToAgent = () => {
console.log('跳转到智能体管理') router.push('/admin/agent')
} }
</script> </script>