Browse Source

三期- 前端会话管理功能

- 统计卡片:展示总会话数、总消息数、今日会话、今日消息
     - 搜索栏:支持按会话ID或消息内容模糊搜索
     - 会话列表表格:展示会话ID、消息数、最后消息时间、预览
     - 消息详情弹窗:分类展示用户/AI/系统消息,带时间戳
     - 导出功能:一键下载 .txt 格式的会话记录
     - 删除功能:逻辑删除整会话的所有消息
master
wanghanlin 1 day ago
parent
commit
4a69fa047f
  1. 28
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  2. 175
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  3. 291
      src/main/java/com/wok/supportbot/service/ConversationService.java
  4. 224
      src/main/resources/static/components/ConversationManager.js
  5. 6
      src/main/resources/static/css/main.css
  6. 47
      src/main/resources/static/js/api.js
  7. 10
      src/main/resources/static/js/app.js

28
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -20,6 +20,13 @@ public class DatabaseInitConfig {
@PostConstruct @PostConstruct
public void init() { public void init() {
try { try {
// 检查 chat_message 表是否存在
boolean chatMessageTableExists = checkTableExists("chat_message");
if (!chatMessageTableExists) {
log.info("创建聊天消息表 chat_message");
createChatMessageTable();
}
// 检查 knowledge_category 表是否存在 // 检查 knowledge_category 表是否存在
boolean categoryTableExists = checkTableExists("knowledge_category"); boolean categoryTableExists = checkTableExists("knowledge_category");
if (!categoryTableExists) { if (!categoryTableExists) {
@ -55,6 +62,27 @@ public class DatabaseInitConfig {
} }
} }
private void createChatMessageTable() {
String sql = """
CREATE TABLE IF NOT EXISTS chat_message (
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
message_type VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}' NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
)
""";
jdbcTemplate.execute(sql);
// 创建索引
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_create_time ON chat_message (create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_type ON chat_message (message_type)");
}
private void createCategoryTable() { private void createCategoryTable() {
String sql = """ String sql = """
CREATE TABLE IF NOT EXISTS knowledge_category ( CREATE TABLE IF NOT EXISTS knowledge_category (

175
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -0,0 +1,175 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.service.ConversationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 会话管理控制器
* 提供会话列表详情消息删除导出等 API
*/
@RestController
public class ConversationController {
@Autowired
private ConversationService conversationService;
// ==================== 会话列表 ====================
/**
* 获取会话列表分页
*
* @param page 页码默认1
* @param size 每页大小默认10
* @param keyword 搜索关键词可选按会话ID或消息内容模糊匹配
* @return 分页会话列表
*/
@GetMapping("/conversation/list")
public ResponseEntity<Map<String, Object>> listConversations(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
try {
Map<String, Object> result = conversationService.listConversations(page, size, keyword);
Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
data.put("total", result.get("total"));
data.put("page", result.get("page"));
data.put("size", result.get("size"));
data.put("pages", result.get("pages"));
return ResponseEntity.ok(data);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
// ==================== 会话详情 ====================
/**
* 获取会话详情
*
* @param conversationId 会话ID
* @return 会话详情
*/
@GetMapping("/conversation/{id}")
public ResponseEntity<Map<String, Object>> getConversationDetail(@PathVariable("id") String conversationId) {
try {
Map<String, Object> detail = conversationService.getConversationDetail(conversationId);
if ((int) detail.get("messageCount") == 0) {
return ResponseEntity.status(404).body(Map.of(
"success", false,
"message", "会话不存在或无任何消息"
));
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", detail
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
/**
* 获取会话消息列表
*
* @param conversationId 会话ID
* @return 消息列表
*/
@GetMapping("/conversation/{id}/messages")
public ResponseEntity<Map<String, Object>> getConversationMessages(@PathVariable("id") String conversationId) {
try {
List<Map<String, Object>> messages = conversationService.getConversationMessages(conversationId);
return ResponseEntity.ok(Map.of(
"success", true,
"data", messages,
"total", messages.size()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
// ==================== 删除会话 ====================
/**
* 删除会话逻辑删除该会话下的所有消息
*
* @param conversationId 会话ID
* @return 删除结果
*/
@DeleteMapping("/conversation/{id}")
public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) {
try {
int count = conversationService.deleteConversation(conversationId);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "会话删除成功",
"deletedMessages", count
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "删除失败:" + e.getMessage()
));
}
}
// ==================== 导出会话 ====================
/**
* 导出会话记录为文本
*
* @param conversationId 会话ID
* @return 格式化后的会话文本
*/
@GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8")
public ResponseEntity<String> exportConversation(@PathVariable("id") String conversationId) {
try {
String content = conversationService.exportConversation(conversationId);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=conversation_" + conversationId + ".txt")
.body(content);
} catch (Exception e) {
return ResponseEntity.status(500).body("导出失败:" + e.getMessage());
}
}
// ==================== 统计 ====================
/**
* 获取会话统计信息
*
* @return 统计信息
*/
@GetMapping("/conversation/stats")
public ResponseEntity<Map<String, Object>> getConversationStats() {
try {
Map<String, Object> stats = conversationService.getConversationStats();
return ResponseEntity.ok(Map.of(
"success", true,
"data", stats
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询统计失败:" + e.getMessage()
));
}
}
}

291
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -0,0 +1,291 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.ChatMessageMapper;
import com.wok.supportbot.entity.ChatMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 会话管理服务
* 提供会话列表详情消息列表删除导出等功能
*/
@Service
@Slf4j
public class ConversationService {
@Autowired
private ChatMessageMapper chatMessageMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
// ==================== 会话列表 ====================
/**
* 获取会话列表分页
*
* @param page 页码从1开始
* @param size 每页大小
* @param keyword 搜索关键词按会话ID或消息内容模糊匹配
* @return 分页会话列表
*/
public Map<String, Object> listConversations(int page, int size, String keyword) {
// 构建基础 SQL 条件
StringBuilder whereClause = new StringBuilder("WHERE is_delete = false");
List<Object> params = new ArrayList<>();
if (keyword != null && !keyword.isEmpty()) {
// 先找出包含关键词的 conversation_id
String matchSql = "SELECT DISTINCT conversation_id FROM chat_message " +
"WHERE is_delete = false AND (conversation_id ILIKE ? OR content ILIKE ?)";
List<String> matchedIds = jdbcTemplate.queryForList(matchSql, String.class,
"%" + keyword + "%", "%" + keyword + "%");
if (matchedIds.isEmpty()) {
// 无匹配结果返回空列表
Map<String, Object> emptyResult = new LinkedHashMap<>();
emptyResult.put("records", List.of());
emptyResult.put("total", 0L);
emptyResult.put("page", page);
emptyResult.put("size", size);
emptyResult.put("pages", 0L);
return emptyResult;
}
// 构建 IN 子句
String inClause = matchedIds.stream()
.map(id -> "'" + id.replace("'", "''") + "'")
.collect(Collectors.joining(","));
whereClause.append(" AND conversation_id IN (").append(inClause).append(")");
}
// 查询总会话数
String countSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message " + whereClause;
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
if (total == null) total = 0L;
// 查询会话列表带统计信息
String listSql = """
SELECT
conversation_id,
COUNT(*) as message_count,
MIN(create_time) as first_message_time,
MAX(create_time) as last_message_time,
(SELECT content FROM chat_message cm2
WHERE cm2.conversation_id = cm1.conversation_id AND cm2.is_delete = false
ORDER BY cm2.create_time DESC LIMIT 1) as last_message_preview
FROM chat_message cm1
""" + whereClause + """
GROUP BY conversation_id
ORDER BY last_message_time DESC
LIMIT ? OFFSET ?
""";
List<Map<String, Object>> records = jdbcTemplate.queryForList(listSql,
size, (page - 1) * size);
// 格式化结果
List<Map<String, Object>> formattedRecords = new ArrayList<>();
for (Map<String, Object> record : records) {
Map<String, Object> formatted = new LinkedHashMap<>();
formatted.put("conversationId", record.get("conversation_id"));
formatted.put("messageCount", ((Number) record.get("message_count")).intValue());
formatted.put("firstMessageTime", record.get("first_message_time"));
formatted.put("lastMessageTime", record.get("last_message_time"));
String preview = (String) record.get("last_message_preview");
formatted.put("lastMessagePreview", preview != null && preview.length() > 100
? preview.substring(0, 100) + "..." : preview);
formattedRecords.add(formatted);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("records", formattedRecords);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
return result;
}
// ==================== 会话详情 ====================
/**
* 获取会话详情
*
* @param conversationId 会话ID
* @return 会话详情
*/
public Map<String, Object> getConversationDetail(String conversationId) {
// 查询会话统计信息
String statsSql = """
SELECT
COUNT(*) as message_count,
COUNT(*) FILTER (WHERE message_type = 'USER') as user_message_count,
COUNT(*) FILTER (WHERE message_type = 'ASSISTANT') as assistant_message_count,
MIN(create_time) as first_message_time,
MAX(create_time) as last_message_time
FROM chat_message
WHERE conversation_id = ? AND is_delete = false
""";
Map<String, Object> stats;
try {
stats = jdbcTemplate.queryForMap(statsSql, conversationId);
} catch (Exception e) {
stats = new HashMap<>();
stats.put("message_count", 0);
stats.put("user_message_count", 0);
stats.put("assistant_message_count", 0);
}
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("conversationId", conversationId);
detail.put("messageCount", ((Number) stats.get("message_count")).intValue());
detail.put("userMessageCount", ((Number) stats.get("user_message_count")).intValue());
detail.put("assistantMessageCount", ((Number) stats.get("assistant_message_count")).intValue());
detail.put("firstMessageTime", stats.get("first_message_time"));
detail.put("lastMessageTime", stats.get("last_message_time"));
return detail;
}
// ==================== 会话消息 ====================
/**
* 获取会话消息列表
*
* @param conversationId 会话ID
* @return 消息列表
*/
public List<Map<String, Object>> getConversationMessages(String conversationId) {
LambdaQueryWrapper<ChatMessage> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ChatMessage::getConversationId, conversationId)
.orderByAsc(ChatMessage::getCreateTime);
List<ChatMessage> messages = chatMessageMapper.selectList(wrapper);
List<Map<String, Object>> result = new ArrayList<>();
for (ChatMessage msg : messages) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", msg.getId());
item.put("conversationId", msg.getConversationId());
item.put("messageType", msg.getMessageType());
item.put("content", msg.getContent());
item.put("metadata", msg.getMetadata());
item.put("createTime", msg.getCreateTime());
result.add(item);
}
return result;
}
// ==================== 删除会话 ====================
/**
* 删除会话逻辑删除该会话下的所有消息
*
* @param conversationId 会话ID
* @return 删除的消息数量
*/
public int deleteConversation(String conversationId) {
LambdaQueryWrapper<ChatMessage> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ChatMessage::getConversationId, conversationId);
// MyBatis Plus @TableLogic 会自动处理逻辑删除
int count = chatMessageMapper.delete(wrapper);
log.info("删除会话: conversationId={}, 删除消息数={}", conversationId, count);
return count;
}
// ==================== 导出会话 ====================
/**
* 导出会话记录为文本格式
*
* @param conversationId 会话ID
* @return 格式化后的会话文本
*/
public String exportConversation(String conversationId) {
Map<String, Object> detail = getConversationDetail(conversationId);
List<Map<String, Object>> messages = getConversationMessages(conversationId);
if (messages.isEmpty()) {
return "会话不存在或无任何消息";
}
StringBuilder sb = new StringBuilder();
sb.append("====================================\n");
sb.append("会话导出记录\n");
sb.append("====================================\n");
sb.append("会话ID: ").append(conversationId).append("\n");
sb.append("消息总数: ").append(detail.get("messageCount")).append("\n");
sb.append("首次消息时间: ").append(detail.get("firstMessageTime")).append("\n");
sb.append("最后消息时间: ").append(detail.get("lastMessageTime")).append("\n");
sb.append("====================================\n\n");
for (Map<String, Object> msg : messages) {
String type = String.valueOf(msg.get("messageType"));
String content = String.valueOf(msg.get("content"));
String time = String.valueOf(msg.get("createTime"));
sb.append("[").append(time).append("] ");
switch (type) {
case "USER" -> sb.append("用户");
case "ASSISTANT" -> sb.append("AI助手");
case "SYSTEM" -> sb.append("系统");
default -> sb.append(type);
}
sb.append("\n");
sb.append(content).append("\n\n");
}
sb.append("====================================\n");
sb.append("导出时间: ").append(new Date()).append("\n");
sb.append("====================================\n");
return sb.toString();
}
// ==================== 统计 ====================
/**
* 获取会话统计信息
*
* @return 统计信息
*/
public Map<String, Object> getConversationStats() {
// 总会话数不重复 conversation_id
String totalSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false";
Long totalConversations = jdbcTemplate.queryForObject(totalSql, Long.class);
if (totalConversations == null) totalConversations = 0L;
// 总消息数
String messageSql = "SELECT COUNT(*) FROM chat_message WHERE is_delete = false";
Long totalMessages = jdbcTemplate.queryForObject(messageSql, Long.class);
if (totalMessages == null) totalMessages = 0L;
// 今日消息数
String todaySql = "SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND create_time >= CURRENT_DATE";
Long todayMessages = jdbcTemplate.queryForObject(todaySql, Long.class);
if (todayMessages == null) todayMessages = 0L;
// 今日会话数
String todayConvSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND create_time >= CURRENT_DATE";
Long todayConversations = jdbcTemplate.queryForObject(todayConvSql, Long.class);
if (todayConversations == null) todayConversations = 0L;
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalConversations", totalConversations);
stats.put("totalMessages", totalMessages);
stats.put("todayConversations", todayConversations);
stats.put("todayMessages", todayMessages);
return stats;
}
}

224
src/main/resources/static/components/ConversationManager.js

@ -0,0 +1,224 @@
/**
* 💬 会话管理组件
* 展示会话列表消息详情删除导出功能
*/
import { ref } from 'vue'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation } from '../js/api.js'
import { toast, formatDate } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>💬 会话列表</h2>
<!-- 统计卡片 -->
<div class="stat-grid" v-if="stats" style="margin-bottom:16px;">
<div class="stat-card">
<div class="number">{{ stats.totalConversations || 0 }}</div>
<div class="label">总会话数</div>
</div>
<div class="stat-card">
<div class="number">{{ stats.totalMessages || 0 }}</div>
<div class="label">总消息数</div>
</div>
<div class="stat-card">
<div class="number">{{ stats.todayConversations || 0 }}</div>
<div class="label">今日会话</div>
</div>
<div class="stat-card">
<div class="number">{{ stats.todayMessages || 0 }}</div>
<div class="label">今日消息</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="input-row">
<input type="text" class="input" v-model="keyword" placeholder="搜索会话ID或消息内容..." @keyup.enter="load(1)">
<button class="btn btn-primary btn-sm" @click="load(1)">🔍 搜索</button>
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button>
</div>
<!-- 会话列表表格 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>会话ID</th>
<th>消息数</th>
<th>最后消息时间</th>
<th>最后消息预览</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="conversations.length === 0">
<td colspan="5" style="text-align:center;color:var(--sub);">暂无会话记录</td>
</tr>
<tr v-for="c in conversations" :key="c.conversationId">
<td>
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.conversationId }}</code>
</td>
<td>{{ c.messageCount }}</td>
<td>{{ formatDate(c.lastMessageTime) }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ c.lastMessagePreview || '-' }}
</td>
<td>
<button class="btn btn-sm btn-outline" @click="viewMessages(c.conversationId)">查看消息</button>
<button class="btn btn-sm btn-primary" @click="downloadExport(c.conversationId)">导出</button>
<button class="btn btn-sm btn-danger" @click="remove(c.conversationId)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="pages > 1">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button>
<template v-for="i in pages" :key="i">
<button v-if="i === 1 || i === pages || (i >= page - 2 && i <= page + 2)"
:class="{ active: i === page }" @click="load(i)">{{ i }}</button>
<span v-else-if="i === page - 3 || i === page + 3" style="padding:6px;">...</span>
</template>
<button :disabled="page >= pages" @click="load(page + 1)">下一页</button>
</div>
</div>
<!-- 消息详情弹窗 -->
<div class="modal-overlay" :class="{ active: messageModal.visible }" @click.self="closeMessageModal">
<div class="modal-box" style="max-width:900px;">
<button class="modal-close" @click="closeMessageModal">×</button>
<h2>💬 会话消息详情</h2>
<p style="font-size:13px;color:var(--sub);margin-bottom:12px;">
会话ID: <code>{{ messageModal.conversationId }}</code>
· {{ messageModal.messages.length }} 条消息
</p>
<div v-if="messageModal.messages.length === 0" style="text-align:center;color:var(--sub);padding:40px;">
暂无消息
</div>
<div v-else class="message-list" style="max-height:500px;overflow-y:auto;">
<div v-for="msg in messageModal.messages" :key="msg.id"
:class="['msg-item', msg.messageType === 'USER' ? 'msg-user' : msg.messageType === 'ASSISTANT' ? 'msg-assistant' : 'msg-system']"
style="margin-bottom:12px;padding:12px;border-radius:8px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="badge" :class="msg.messageType === 'USER' ? 'badge-get' : msg.messageType === 'ASSISTANT' ? 'badge-post' : ''">
{{ msg.messageType === 'USER' ? '用户' : msg.messageType === 'ASSISTANT' ? 'AI助手' : '系统' }}
</span>
<span style="font-size:11px;color:var(--sub);">{{ formatDate(msg.createTime) }}</span>
</div>
<div style="font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;">{{ msg.content }}</div>
</div>
</div>
</div>
</div>
`,
setup() {
const conversations = ref([])
const page = ref(1)
const pages = ref(1)
const total = ref(0)
const keyword = ref('')
const stats = ref(null)
// 消息详情弹窗
const messageModal = ref({
visible: false,
conversationId: '',
messages: []
})
async function load(p = 1) {
page.value = p
try {
const json = await listConversations(p, 10, keyword.value || undefined)
if (json.success) {
conversations.value = json.data || []
total.value = json.total || 0
pages.value = json.pages || 1
} else {
toast(json.message || '查询失败', 'error')
}
} catch (e) {
toast('加载会话失败:' + e.message, 'error')
}
}
async function loadStats() {
try {
const json = await getConversationStats()
if (json.success) {
stats.value = json.data
}
} catch (e) {
console.error('加载会话统计失败', e)
}
}
async function viewMessages(conversationId) {
try {
const json = await getConversationMessages(conversationId)
if (json.success) {
messageModal.value.conversationId = conversationId
messageModal.value.messages = json.data || []
messageModal.value.visible = true
} else {
toast(json.message || '加载消息失败', 'error')
}
} catch (e) {
toast('加载消息失败:' + e.message, 'error')
}
}
function closeMessageModal() {
messageModal.value.visible = false
messageModal.value.conversationId = ''
messageModal.value.messages = []
}
async function remove(conversationId) {
if (!confirm('确定删除此会话?该会话下的所有消息将被逻辑删除')) return
try {
const json = await deleteConversation(conversationId)
if (json.success) {
toast(`会话删除成功,共删除 ${json.deletedMessages || 0} 条消息`, 'success')
load(page.value)
loadStats()
} else {
toast(json.message || '删除失败', 'error')
}
} catch (e) {
toast('删除失败:' + e.message, 'error')
}
}
async function downloadExport(conversationId) {
try {
const content = await exportConversation(conversationId)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `conversation_${conversationId}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast('导出成功', 'success')
} catch (e) {
toast('导出失败:' + e.message, 'error')
}
}
// 初始加载
load()
loadStats()
return {
conversations, page, pages, total, keyword, stats, messageModal,
load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate
}
}
}

6
src/main/resources/static/css/main.css

@ -132,5 +132,11 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.badge-post { background:#fef3c7; color:#b45309; } .badge-post { background:#fef3c7; color:#b45309; }
.endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; } .endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
/* ==================== 消息列表样式(会话管理) ==================== */
.msg-item { border:1px solid var(--border); }
.msg-item.msg-user { background:#f0f9ff; border-left:3px solid var(--primary); }
.msg-item.msg-assistant { background:#f0fdf4; border-left:3px solid var(--success); }
.msg-item.msg-system { background:#f9fafb; border-left:3px solid var(--sub); }
/* ==================== 响应式 ==================== */ /* ==================== 响应式 ==================== */
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } } @media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } }

47
src/main/resources/static/js/api.js

@ -337,3 +337,50 @@ export function updateCategory(id, data) {
export function deleteCategory(id) { export function deleteCategory(id) {
return deleteJSON(`/category/${id}`) return deleteJSON(`/category/${id}`)
} }
// ==================== 会话管理 ====================
/**
* 会话列表分页
*/
export function listConversations(page = 1, size = 10, keyword) {
let path = `/conversation/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
return getJSON(path)
}
/**
* 会话详情
*/
export function getConversationDetail(conversationId) {
return getJSON(`/conversation/${conversationId}`)
}
/**
* 会话消息列表
*/
export function getConversationMessages(conversationId) {
return getJSON(`/conversation/${conversationId}/messages`)
}
/**
* 删除会话
*/
export function deleteConversation(conversationId) {
return deleteJSON(`/conversation/${conversationId}`)
}
/**
* 导出会话记录
*/
export function exportConversation(conversationId) {
return fetch(API_BASE + `/conversation/${conversationId}/export`)
.then(res => res.text())
}
/**
* 会话统计
*/
export function getConversationStats() {
return getJSON('/conversation/stats')
}

10
src/main/resources/static/js/app.js

@ -14,6 +14,7 @@ import CategoryManager from '../components/CategoryManager.js'
import DocList from '../components/DocList.js' import DocList from '../components/DocList.js'
import DocUpload from '../components/DocUpload.js' import DocUpload from '../components/DocUpload.js'
import DocDetail from '../components/DocDetail.js' import DocDetail from '../components/DocDetail.js'
import ConversationManager from '../components/ConversationManager.js'
const app = createApp({ const app = createApp({
setup() { setup() {
@ -50,6 +51,9 @@ const app = createApp({
<button :class="['tab-btn', activeTab === 'document' ? 'active' : '']" @click="switchTab('document')"> <button :class="['tab-btn', activeTab === 'document' ? 'active' : '']" @click="switchTab('document')">
<span class="tab-icon">📄</span> <span class="tab-icon">📄</span>
</button> </button>
<button :class="['tab-btn', activeTab === 'conversation' ? 'active' : '']" @click="switchTab('conversation')">
<span class="tab-icon">💬</span>
</button>
</div> </div>
<!-- 内容区 --> <!-- 内容区 -->
@ -73,6 +77,11 @@ const app = createApp({
<doc-upload></doc-upload> <doc-upload></doc-upload>
</div> </div>
<!-- Tab 4: 会话管理 -->
<div v-if="activeTab === 'conversation'" style="animation: fadeIn .3s ease;">
<conversation-manager></conversation-manager>
</div>
<!-- 文档详情弹窗 --> <!-- 文档详情弹窗 -->
<doc-detail></doc-detail> <doc-detail></doc-detail>
</div> </div>
@ -91,5 +100,6 @@ app.component('category-manager', CategoryManager)
app.component('doc-list', DocList) app.component('doc-list', DocList)
app.component('doc-upload', DocUpload) app.component('doc-upload', DocUpload)
app.component('doc-detail', DocDetail) app.component('doc-detail', DocDetail)
app.component('conversation-manager', ConversationManager)
app.mount('#app') app.mount('#app')
Loading…
Cancel
Save