Browse Source

一期-前端重构

master
wanghanlin 2 days ago
parent
commit
cff087fad0
  1. 13
      CLAUDE.md
  2. 31
      README.md
  3. 178
      chat.html
  4. 1098
      frontend.html
  5. 178
      src/main/resources/static/chat.html
  6. 76
      src/main/resources/static/components/CategoryManager.js
  7. 140
      src/main/resources/static/components/ChatPanel.js
  8. 61
      src/main/resources/static/components/DocDetail.js
  9. 145
      src/main/resources/static/components/DocList.js
  10. 71
      src/main/resources/static/components/DocSearch.js
  11. 39
      src/main/resources/static/components/DocStats.js
  12. 264
      src/main/resources/static/components/DocUpload.js
  13. 80
      src/main/resources/static/components/ProductPanel.js
  14. 136
      src/main/resources/static/css/main.css
  15. 1098
      src/main/resources/static/frontend.html
  16. 20
      src/main/resources/static/index.html
  17. 259
      src/main/resources/static/js/api.js
  18. 95
      src/main/resources/static/js/app.js
  19. 86
      src/main/resources/static/js/store.js
  20. 99
      src/main/resources/static/js/utils.js

13
CLAUDE.md

@ -55,9 +55,20 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- `application.yml` 含 API Key,已被 `.gitignore` 排除 - `application.yml` 含 API Key,已被 `.gitignore` 排除
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法) - MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象,非数组)
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`
- 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW - 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW
## 前端架构
- **技术栈**: Vue 3 CDN + ES Module(`importmap` 引入,无构建工具)
- **入口**: `src/main/resources/static/index.html``js/app.js`
- **组件化**: 每个功能模块一个 JS 文件(`components/` 目录),导出 Vue 组件定义对象
- **状态管理**: `js/store.js` 使用 Vue 3 `reactive`,跨组件共享分类、统计、弹窗状态
- **API 封装**: `js/api.js` 统一封装所有后端调用,API 基址为空字符串(同源部署)
- **SSE 流式**: `js/utils.js``readSSEStream()` 统一处理三种 SSE 接口
- **添加新功能**: 在 `components/` 下新建 JS 组件文件,在 `app.js` 中导入注册即可
## API 路由约定 ## API 路由约定
- AI 对话: `/ai/*`(`AiController`) - AI 对话: `/ai/*`(`AiController`)

31
README.md

@ -213,7 +213,10 @@ src/main/resources/
├── support-bot.sql # 数据库初始化脚本 ├── support-bot.sql # 数据库初始化脚本
├── knowledge-base.sql # 知识库增量迁移脚本 ├── knowledge-base.sql # 知识库增量迁移脚本
└── static/ └── static/
└── frontend.html # 前端管理页面
├── index.html # 前端入口页面
├── css/main.css # 全局样式
├── js/ # Vue 应用、API、状态、工具
└── components/ # Vue 组件
``` ```
## 🧩 文档处理管道 ## 🧩 文档处理管道
@ -234,7 +237,7 @@ src/main/resources/
## 🖥️ 前端管理页面 ## 🖥️ 前端管理页面
访问 `http://localhost:9090/frontend.html`,包含三个标签页:
访问 `http://localhost:9090/index.html`,包含三个标签页:
| 标签页 | 功能 | | 标签页 | 功能 |
|--------|------| |--------|------|
@ -242,6 +245,28 @@ src/main/resources/
| 🏷️ 商品信息提取 | AI 结构化提取商品信息 | | 🏷️ 商品信息提取 | AI 结构化提取商品信息 |
| 📄 知识库文档管理 | 文档上传(6种格式)、分类管理、文档列表、语义搜索测试、统计面板、文档详情查看 | | 📄 知识库文档管理 | 文档上传(6种格式)、分类管理、文档列表、语义搜索测试、统计面板、文档详情查看 |
前端采用 **Vue 3 CDN + ES Module** 组件化架构,无需构建工具,文件结构如下:
```
src/main/resources/static/
├── index.html # 入口页面
├── css/main.css # 全局样式
├── js/
│ ├── app.js # Vue 应用入口
│ ├── api.js # 统一 API 请求层
│ ├── store.js # 共享响应式状态
│ └── utils.js # 工具函数 + SSE 流式读取
└── components/
├── ChatPanel.js # 对话面板
├── ProductPanel.js # 商品信息提取
├── DocStats.js # 统计面板
├── DocSearch.js # 语义搜索
├── CategoryManager.js # 分类管理
├── DocList.js # 文档列表 + 分页
├── DocUpload.js # 文档上传
└── DocDetail.js # 文档详情弹窗
```
## ⚡ 快速开始 ## ⚡ 快速开始
### 1. 环境准备 ### 1. 环境准备
@ -288,7 +313,7 @@ spring:
### 5. 访问 ### 5. 访问
- 前端管理页面:http://localhost:9090/frontend.html
- 前端管理页面:http://localhost:9090/index.html
- API 文档:http://localhost:9090/doc.html - API 文档:http://localhost:9090/doc.html
## 📋 查询优化策略 ## 📋 查询优化策略

178
chat.html

@ -1,178 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 智能客服 - 对话窗口</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "Microsoft YaHei", sans-serif;
background: #f0f2f5;
display: flex; justify-content: center; align-items: center;
height: 100vh;
}
.chat-container {
width: 800px; max-width: 95vw; height: 90vh;
background: #fff; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
display: flex; flex-direction: column; overflow: hidden;
}
.chat-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; padding: 16px 20px;
font-size: 18px; font-weight: 600;
}
.chat-header span { font-size: 13px; opacity: 0.8; margin-left: 8px; }
.chat-messages {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 12px;
background: #fafbfc;
}
.message {
display: flex; gap: 10px; max-width: 80%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.message.user { align-self: flex-end; }
.message.assistant { align-self: flex-start; }
.avatar {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
}
.message.user .avatar { background: #667eea; }
.message.assistant .avatar { background: #10b981; }
.bubble {
padding: 10px 16px; border-radius: 12px;
line-height: 1.6; font-size: 14px; word-break: break-word;
}
.message.user .bubble { background: #667eea; color: #fff; border-bottom-right-radius: 4px; }
.message.assistant .bubble { background: #fff; color: #333; border: 1px solid #e8e8e8; border-bottom-left-radius: 4px; }
.chat-input-area {
padding: 16px 20px; border-top: 1px solid #eee;
display: flex; gap: 10px; background: #fff;
}
.chat-input-area input {
flex: 1; padding: 12px 16px;
border: 1px solid #ddd; border-radius: 24px;
font-size: 14px; outline: none; transition: border-color 0.3s;
}
.chat-input-area input:focus { border-color: #667eea; }
.chat-input-area button {
padding: 12px 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; border: none; border-radius: 24px;
font-size: 14px; cursor: pointer; transition: opacity 0.3s;
}
.chat-input-area button:hover { opacity: 0.9; }
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
.typing-indicator {
display: flex; gap: 4px; padding: 10px 16px;
}
.typing-indicator span {
width: 8px; height: 8px; background: #999;
border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
.info-bar {
padding: 8px 20px; background: #f0f2f5; font-size: 12px;
color: #999; display: flex; justify-content: space-between;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
🤖 AI 智能客服<span>基于通义千问 · 支持多轮对话</span>
</div>
<div class="info-bar">
<span>会话ID: <strong id="chatIdDisplay">---</strong></span>
<span><a href="http://localhost:8080/doc.html" target="_blank" style="color:#667eea;">📖 API文档</a></span>
</div>
<div class="chat-messages" id="messages">
<div class="message assistant">
<div class="avatar">🤖</div>
<div class="bubble">
您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。<br>请问有什么可以帮您的?
</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" id="userInput" placeholder="输入您的问题,按回车发送..."
onkeydown="if(event.key==='Enter') sendMessage()" autofocus>
<button id="sendBtn" onclick="sendMessage()">发送</button>
</div>
</div>
<script>
// 生成唯一会话 ID
const chatId = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
document.getElementById('chatIdDisplay').textContent = chatId;
const API_BASE = 'http://localhost:8080';
function addMessage(role, content) {
const div = document.createElement('div');
div.className = 'message ' + role;
const avatar = role === 'user' ? '👤' : '🤖';
if (role === 'user') {
div.innerHTML = `<div class="bubble">${escapeHtml(content)}</div><div class="avatar">${avatar}</div>`;
} else {
div.innerHTML = `<div class="avatar">${avatar}</div><div class="bubble">${escapeHtml(content)}</div>`;
}
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
function addTyping() {
const div = document.createElement('div');
div.className = 'message assistant';
div.id = 'typing-msg';
div.innerHTML = `<div class="avatar">🤖</div><div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`;
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
function removeTyping() {
const el = document.getElementById('typing-msg');
if (el) el.remove();
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function sendMessage() {
const input = document.getElementById('userInput');
const btn = document.getElementById('sendBtn');
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
btn.disabled = true;
addMessage('user', message);
addTyping();
try {
const url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
removeTyping();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
addMessage('assistant', text);
} catch (err) {
removeTyping();
addMessage('assistant', '抱歉,请求失败:' + err.message + '。请确认后端服务已启动。');
} finally {
input.disabled = false;
btn.disabled = false;
input.focus();
}
}
</script>
</body>
</html>

1098
frontend.html
File diff suppressed because it is too large
View File

178
src/main/resources/static/chat.html

@ -1,178 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 智能客服 - 对话窗口</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "Microsoft YaHei", sans-serif;
background: #f0f2f5;
display: flex; justify-content: center; align-items: center;
height: 100vh;
}
.chat-container {
width: 800px; max-width: 95vw; height: 90vh;
background: #fff; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
display: flex; flex-direction: column; overflow: hidden;
}
.chat-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; padding: 16px 20px;
font-size: 18px; font-weight: 600;
}
.chat-header span { font-size: 13px; opacity: 0.8; margin-left: 8px; }
.chat-messages {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 12px;
background: #fafbfc;
}
.message {
display: flex; gap: 10px; max-width: 80%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.message.user { align-self: flex-end; }
.message.assistant { align-self: flex-start; }
.avatar {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
}
.message.user .avatar { background: #667eea; }
.message.assistant .avatar { background: #10b981; }
.bubble {
padding: 10px 16px; border-radius: 12px;
line-height: 1.6; font-size: 14px; word-break: break-word;
}
.message.user .bubble { background: #667eea; color: #fff; border-bottom-right-radius: 4px; }
.message.assistant .bubble { background: #fff; color: #333; border: 1px solid #e8e8e8; border-bottom-left-radius: 4px; }
.chat-input-area {
padding: 16px 20px; border-top: 1px solid #eee;
display: flex; gap: 10px; background: #fff;
}
.chat-input-area input {
flex: 1; padding: 12px 16px;
border: 1px solid #ddd; border-radius: 24px;
font-size: 14px; outline: none; transition: border-color 0.3s;
}
.chat-input-area input:focus { border-color: #667eea; }
.chat-input-area button {
padding: 12px 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; border: none; border-radius: 24px;
font-size: 14px; cursor: pointer; transition: opacity 0.3s;
}
.chat-input-area button:hover { opacity: 0.9; }
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
.typing-indicator {
display: flex; gap: 4px; padding: 10px 16px;
}
.typing-indicator span {
width: 8px; height: 8px; background: #999;
border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
.info-bar {
padding: 8px 20px; background: #f0f2f5; font-size: 12px;
color: #999; display: flex; justify-content: space-between;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
🤖 AI 智能客服<span>基于通义千问 · 支持多轮对话</span>
</div>
<div class="info-bar">
<span>会话ID: <strong id="chatIdDisplay">---</strong></span>
<span><a href="http://localhost:8080/doc.html" target="_blank" style="color:#667eea;">📖 API文档</a></span>
</div>
<div class="chat-messages" id="messages">
<div class="message assistant">
<div class="avatar">🤖</div>
<div class="bubble">
您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。<br>请问有什么可以帮您的?
</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" id="userInput" placeholder="输入您的问题,按回车发送..."
onkeydown="if(event.key==='Enter') sendMessage()" autofocus>
<button id="sendBtn" onclick="sendMessage()">发送</button>
</div>
</div>
<script>
// 生成唯一会话 ID
const chatId = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
document.getElementById('chatIdDisplay').textContent = chatId;
const API_BASE = 'http://localhost:8080';
function addMessage(role, content) {
const div = document.createElement('div');
div.className = 'message ' + role;
const avatar = role === 'user' ? '👤' : '🤖';
if (role === 'user') {
div.innerHTML = `<div class="bubble">${escapeHtml(content)}</div><div class="avatar">${avatar}</div>`;
} else {
div.innerHTML = `<div class="avatar">${avatar}</div><div class="bubble">${escapeHtml(content)}</div>`;
}
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
function addTyping() {
const div = document.createElement('div');
div.className = 'message assistant';
div.id = 'typing-msg';
div.innerHTML = `<div class="avatar">🤖</div><div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`;
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
function removeTyping() {
const el = document.getElementById('typing-msg');
if (el) el.remove();
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function sendMessage() {
const input = document.getElementById('userInput');
const btn = document.getElementById('sendBtn');
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
btn.disabled = true;
addMessage('user', message);
addTyping();
try {
const url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`;
const res = await fetch(url);
removeTyping();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
addMessage('assistant', text);
} catch (err) {
removeTyping();
addMessage('assistant', '抱歉,请求失败:' + err.message + '。请确认后端服务已启动。');
} finally {
input.disabled = false;
btn.disabled = false;
input.focus();
}
}
</script>
</body>
</html>

76
src/main/resources/static/components/CategoryManager.js

@ -0,0 +1,76 @@
/**
* 🏷 分类管理
*/
import { ref } from 'vue'
import { store } from '../js/store.js'
import { createCategory, deleteCategory } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>🏷 分类管理</h2>
<div class="input-row">
<input class="input" v-model="name" placeholder="分类名称">
<input class="input" v-model="description" placeholder="分类描述(可选)">
<input class="input input-sm" v-model.number="sortOrder" placeholder="排序" type="number">
<button class="btn btn-success" @click="create"> 创建分类</button>
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">🔄 刷新</button>
</div>
<div v-if="store.categories.length === 0" style="margin-top:12px;font-size:13px;">暂无分类</div>
<div v-else style="margin-top:12px;font-size:13px;">
<div v-for="c in store.categories" :key="c.id" style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span><strong>{{ c.name }}</strong> <span style="color:var(--sub);font-size:12px;">{{ c.description || '' }}</span></span>
<button class="btn btn-danger btn-sm" @click="remove(c.id)">删除</button>
</div>
</div>
</div>
`,
setup() {
const name = ref('')
const description = ref('')
const sortOrder = ref(0)
async function create() {
if (!name.value.trim()) {
toast('请输入分类名称', 'error')
return
}
try {
const json = await createCategory({
name: name.value,
description: description.value,
sortOrder: sortOrder.value
})
if (json.success) {
toast('分类创建成功', 'success')
name.value = ''
description.value = ''
store.loadCategories()
} else {
toast(json.message || '创建失败', 'error')
}
} catch (e) {
toast('创建分类失败:' + e.message, 'error')
}
}
async function remove(id) {
if (!confirm('确定删除此分类?关联文档将变为未分类')) return
try {
const json = await deleteCategory(id)
if (json.success) {
toast('分类已删除', 'success')
store.loadCategories()
} else {
toast(json.message || '删除失败', 'error')
}
} catch (e) {
toast('删除分类失败:' + e.message, 'error')
}
}
return { name, description, sortOrder, store, create, remove }
}
}

140
src/main/resources/static/components/ChatPanel.js

@ -0,0 +1,140 @@
/**
* 💬 智能客服对话面板
*/
import { ref, nextTick } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl } from '../js/api.js'
import { toast, readSSEStream } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>💬 智能客服对话</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆</p>
<div class="input-row">
<input class="input input-sm" v-model="chatId" placeholder="会话ID">
<select class="select" v-model="mode">
<option value="sync">🔵 同步调用</option>
<option value="sse">🟢 SSE 流式 (Flux)</option>
<option value="sse2">🟡 ServerSentEvent</option>
<option value="sse3">🟣 SseEmitter</option>
</select>
<button class="btn btn-outline btn-sm" @click="newChatId">🔄 新会话</button>
<button class="btn btn-outline btn-sm" @click="clearMessages">🗑 清屏</button>
</div>
<div class="input-row" style="margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
<input type="checkbox" v-model="isRagMode" style="width:16px;height:16px;cursor:pointer;">
<span style="font-weight:600;color:var(--primary);">📚 启用 RAG 知识库检索</span>
</label>
<select class="select" v-model="ragStrategy" v-show="isRagMode" style="margin-left:auto;">
<option value="NONE">无重写</option>
<option value="REWRITE">查询重写</option>
<option value="TRANSLATION">翻译扩展</option>
<option value="COMPRESSION">查询压缩</option>
<option value="MULTI_QUERY">多路扩展</option>
</select>
</div>
<div v-show="isRagMode" style="margin-bottom:12px;padding:8px 12px;background:#eef2ff;border-radius:6px;font-size:12px;color:var(--primary);border-left:3px solid var(--primary);">
💡 当前已启用 RAG 知识库检索策略<strong>{{ ragStrategyNames[ragStrategy] || ragStrategy }}</strong>
</div>
<div class="msg-area" ref="msgArea">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-avatar">{{ m.role === 'user' ? '👤' : '🤖' }}</div>
<div class="msg-bubble">{{ m.content }}</div>
</div>
</div>
<div class="input-row">
<input class="input" v-model="userInput" placeholder="输入问题,Enter 发送..." @keydown.enter="send" :disabled="isSending">
<button class="btn btn-primary" @click="send" :disabled="isSending">📨 发送</button>
</div>
</div>
`,
setup() {
const chatId = ref('')
const mode = ref('sync')
const isRagMode = ref(false)
const ragStrategy = ref('REWRITE')
const userInput = ref('')
const isSending = ref(false)
const messages = ref([
{ role: 'assistant', content: '您好!我是电商智能客服助手。\n可以帮您解答商品、订单、支付、物流和售后问题。\n\n💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。\n📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。', streaming: false }
])
const msgArea = ref(null)
const ragStrategyNames = {
NONE: '无重写', REWRITE: '查询重写', TRANSLATION: '翻译扩展',
COMPRESSION: '查询压缩', MULTI_QUERY: '多路扩展'
}
function newChatId() {
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6)
}
function clearMessages() {
messages.value = [{ role: 'assistant', content: '已清屏,开始新的对话吧~', streaming: false }]
}
async function send() {
const text = userInput.value.trim()
if (!text || isSending.value) return
userInput.value = ''
isSending.value = true
// 添加用户消息
messages.value.push({ role: 'user', content: text, streaming: false })
// 添加助手占位
const assistantMsg = { role: 'assistant', content: '', streaming: true }
messages.value.push(assistantMsg)
const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid
try {
if (isRagMode.value && mode.value === 'sync') {
// RAG 同步
const result = await chatRagSync(text, cid, ragStrategy.value)
assistantMsg.content = result
} else if (isRagMode.value && mode.value !== 'sync') {
assistantMsg.content = '⚠️ RAG 模式仅支持同步调用'
toast('RAG 模式暂不支持流式调用', 'error')
} else if (mode.value === 'sync') {
// 普通同步
const result = await chatSync(text, cid)
assistantMsg.content = result
} else {
// SSE 流式
const url = chatSSEUrl(text, cid, mode.value)
await readSSEStream(url,
(chunk) => { assistantMsg.content += chunk },
() => {}
)
}
} catch (e) {
assistantMsg.content = '请求失败:' + e.message
toast('对话失败:' + e.message, 'error')
} finally {
assistantMsg.streaming = false
isSending.value = false
// 滚动到底部
nextTick(() => {
if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight
})
}
}
// 初始化会话 ID
newChatId()
return {
chatId, mode, isRagMode, ragStrategy, ragStrategyNames,
userInput, isSending, messages, msgArea,
newChatId, clearMessages, send
}
}
}

61
src/main/resources/static/components/DocDetail.js

@ -0,0 +1,61 @@
/**
* 📄 文档详情弹窗
*/
import { computed } from 'vue'
import { store } from '../js/store.js'
import { formatBytes, formatDate } from '../js/utils.js'
export default {
template: `
<div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()">
<div class="modal-box">
<button class="modal-close" @click="store.closeDetail()">&times;</button>
<h2>📄 文档详情</h2>
<template v-if="store.detailModal.doc">
<div style="margin-bottom:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div class="result-item"><div class="label">文档标题</div><div class="value">{{ store.detailModal.doc.title }}</div></div>
<div class="result-item"><div class="label">原始文件名</div><div class="value">{{ store.detailModal.doc.sourceName || '-' }}</div></div>
<div class="result-item"><div class="label">文件类型</div><div class="value">{{ store.detailModal.doc.fileType }}</div></div>
<div class="result-item"><div class="label">文件大小</div><div class="value">{{ formatBytes(store.detailModal.doc.fileSize || 0) }}</div></div>
<div class="result-item"><div class="label">状态</div><div class="value"><span :class="statusClass">{{ store.detailModal.doc.status }}</span></div></div>
<div class="result-item"><div class="label">分块数</div><div class="value">{{ store.detailModal.doc.chunkCount }}</div></div>
<div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div>
<div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div>
</div>
<h3>📄 原文内容</h3>
<div style="background:#f9fafb;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">{{ store.detailModal.doc.content || '-' }}</div>
</div>
<h3>🧩 分块详情{{ store.detailModal.chunks.length }} </h3>
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result">
<div class="meta">#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}</div>
<div class="content">{{ chunk.content || '' }}</div>
</div>
</template>
</div>
</div>
`,
setup() {
const statusClass = computed(() => {
const status = store.detailModal.doc?.status
return status === 'READY' ? 'status-ready'
: status === 'PROCESSING' ? 'status-processing'
: 'status-failed'
})
function getKeywords(chunk) {
let meta = chunk.metadata
if (typeof meta === 'object' && meta && meta.value) meta = meta.value
try {
const m = typeof meta === 'string' ? JSON.parse(meta) : meta
return m.excerpt_keywords || ''
} catch (e) {
return ''
}
}
return { store, statusClass, getKeywords, formatBytes, formatDate }
}
}

145
src/main/resources/static/components/DocList.js

@ -0,0 +1,145 @@
/**
* 📋 文档列表 + 分页
*/
import { ref } from 'vue'
import { store } from '../js/store.js'
import { listDocuments, deleteDocument, reprocessDocument } from '../js/api.js'
import { toast, formatDate } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📋 文档列表</h2>
<div class="input-row">
<select class="select" v-model="filterCategory" @change="load()">
<option value="">全部分类</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select class="select" v-model="filterStatus" @change="load()">
<option value="">全部状态</option>
<option value="READY"> 已完成</option>
<option value="PROCESSING"> 处理中</option>
<option value="FAILED"> 失败</option>
</select>
<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>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="documents.length === 0">
<td colspan="7" style="text-align:center;color:var(--sub);">暂无文档</td>
</tr>
<tr v-for="d in documents" :key="d.id">
<td>{{ d.id }}</td>
<td>
<strong>{{ d.title }}</strong><br>
<span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span>
</td>
<td><span class="category-tag">{{ d.fileType }}</span></td>
<td><span :class="statusClass(d.status)">{{ d.status }}</span></td>
<td>{{ d.chunkCount }}</td>
<td>{{ formatDate(d.createTime) }}</td>
<td>
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button>
<button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button>
<button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</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>
`,
setup() {
const documents = ref([])
const page = ref(1)
const pages = ref(1)
const total = ref(0)
const filterCategory = ref('')
const filterStatus = ref('')
function statusClass(status) {
return status === 'READY' ? 'status-ready'
: status === 'PROCESSING' ? 'status-processing'
: 'status-failed'
}
async function load(p = 1) {
page.value = p
try {
const json = await listDocuments(p, 10, filterCategory.value || undefined, filterStatus.value || undefined)
if (json.success) {
documents.value = json.data || []
total.value = json.total || 0
pages.value = json.pages || 1
} else {
toast(json.message || '查询失败', 'error')
}
} catch (e) {
toast('加载文档失败:' + e.message, 'error')
}
}
function viewDetail(id) {
store.openDetail(id)
}
async function remove(id) {
if (!confirm('确定删除此文档?关联的向量也将被删除')) return
try {
const json = await deleteDocument(id)
if (json.success) {
toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success')
load(page.value)
store.loadStats()
} else {
toast(json.message || '删除失败', 'error')
}
} catch (e) {
toast('删除失败:' + e.message, 'error')
}
}
async function reprocess(id) {
if (!confirm('确定重新处理此文档?将重新分块并向量化')) return
try {
const json = await reprocessDocument(id)
if (json.success) {
toast('重新处理成功', 'success')
load(page.value)
} else {
toast(json.message || '重新处理失败', 'error')
}
} catch (e) {
toast('重新处理失败:' + e.message, 'error')
}
}
return {
documents, page, pages, total, filterCategory, filterStatus,
store, statusClass, load, viewDetail, remove, reprocess, formatDate
}
}
}

71
src/main/resources/static/components/DocSearch.js

@ -0,0 +1,71 @@
/**
* 🔍 语义搜索测试
*/
import { ref } from 'vue'
import { searchDocuments } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>🔍 语义搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句测试知识库检索效果</p>
<div class="input-row">
<input class="input" v-model="query" placeholder="输入查询,如:Spring Boot 是什么?" @keydown.enter="search">
<input class="input input-sm" v-model.number="topK" placeholder="TopK" type="number" min="1" max="20">
<input class="input input-sm" v-model.number="threshold" placeholder="阈值" type="number" min="0" max="1" step="0.1">
<button class="btn btn-primary" @click="search" :disabled="isSearching">{{ isSearching ? '⏳ 搜索中...' : '🔍 搜索' }}</button>
</div>
<div v-if="isSearching" style="text-align:center;padding:20px;color:var(--sub);"> 搜索中...</div>
<div v-else-if="searched && results.length === 0" style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div>
<div v-else-if="searched && errorMsg" style="color:var(--danger);">搜索失败{{ errorMsg }}</div>
<div v-else>
<div v-for="(r, i) in results" :key="i" class="search-result">
<div class="score">相似度得分: {{ r.score !== null ? (1 - r.score).toFixed(4) : '-' }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}</div>
<div class="content">{{ r.content || '' }}</div>
</div>
</div>
</div>
`,
setup() {
const query = ref('')
const topK = ref(5)
const threshold = ref(0.5)
const results = ref([])
const isSearching = ref(false)
const searched = ref(false)
const errorMsg = ref('')
async function search() {
if (!query.value.trim()) {
toast('请输入查询内容', 'error')
return
}
isSearching.value = true
searched.value = false
errorMsg.value = ''
try {
const json = await searchDocuments(query.value, topK.value, threshold.value)
if (!json.success) {
errorMsg.value = json.message
results.value = []
} else {
results.value = json.data || []
}
searched.value = true
} catch (e) {
errorMsg.value = e.message
results.value = []
searched.value = true
} finally {
isSearching.value = false
}
}
return { query, topK, threshold, results, isSearching, searched, errorMsg, search }
}
}

39
src/main/resources/static/components/DocStats.js

@ -0,0 +1,39 @@
/**
* 📊 知识库统计面板
*/
import { computed } from 'vue'
import { store } from '../js/store.js'
import { formatDate } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📊 知识库概览</h2>
<div class="stat-grid">
<div class="stat-card">
<div class="number">{{ store.stats?.totalDocuments || 0 }}</div>
<div class="label">文档总数</div>
</div>
<div class="stat-card">
<div class="number">{{ store.stats?.totalVectors || 0 }}</div>
<div class="label">向量总数</div>
</div>
<div class="stat-card">
<div class="number">{{ store.stats?.lastUploadTime ? formatDate(store.stats.lastUploadTime) : '-' }}</div>
<div class="label">最近上传</div>
</div>
</div>
<div style="font-size:13px;color:var(--sub);">{{ fileTypeDetail }}</div>
</div>
`,
setup() {
const fileTypeDetail = computed(() => {
if (!store.stats?.byFileType) return ''
const entries = Object.entries(store.stats.byFileType)
if (entries.length === 0) return ''
return '文件类型: ' + entries.map(([k, v]) => `${k}: ${v}`).join(', ')
})
return { store, fileTypeDetail, formatDate }
}
}

264
src/main/resources/static/components/DocUpload.js

@ -0,0 +1,264 @@
/**
* 📤 文档上传面板
* 支持 6 种上传格式通用文件文本MarkdownJSON3种模式
*/
import { ref, reactive } from 'vue'
import { store } from '../js/store.js'
import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js'
import { toast, formatBytes } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📤 文档上传</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库自动分词 向量化 存入 PGVector</p>
<!-- 上传元信息 -->
<div class="input-row" style="padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<select class="select" v-model="uploadCategory">
<option value="">选择分类可选</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<input class="input" v-model="uploadTags" placeholder="标签,逗号分隔(可选)">
</div>
<!-- Tab -->
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;">
<button v-for="t in subTabs" :key="t.key"
:class="['btn', 'btn-sm', activeSubTab === t.key ? 'btn-primary' : '']"
@click="activeSubTab = t.key">{{ t.icon }} {{ t.label }}</button>
</div>
<!-- 通用文件 -->
<div v-show="activeSubTab === 'file'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">Tika </span></div>
<div class="upload-zone" @click="$refs.fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')">
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT </p>
<input type="file" ref="fileInput" style="display:none" multiple @change="handleFileSelect($event, 'file')">
</div>
<div v-html="fileInfo.file" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<button class="btn btn-primary" @click="doUpload('file')" :disabled="!fileData.file">🚀 上传并向量化</button>
<div v-html="results.file"></div>
</div>
<!-- 文本内容 -->
<div v-show="activeSubTab === 'string'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/string</code><span style="font-size:12px;color:var(--sub);"></span></div>
<input class="input" v-model="stringTitle" placeholder="文档标题" style="margin-bottom:8px;">
<textarea class="textarea" v-model="stringContent" placeholder="输入要加入知识库的文本内容...
例如公司退换货政策商品FAQ物流说明等"></textarea>
<button class="btn btn-primary" style="margin-top:12px;" @click="doUpload('string')">🚀 上传并向量化</button>
<div v-html="results.string"></div>
</div>
<!-- Markdown -->
<div v-show="activeSubTab === 'markdown'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/markdown</code></div>
<div class="upload-zone" @click="$refs.mdInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'markdown')">
<div class="icon">📑</div><p>Markdown .md</p>
<input type="file" ref="mdInput" style="display:none" accept=".md" multiple @change="handleFileSelect($event, 'markdown')">
</div>
<div v-html="fileInfo.markdown" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="!fileData.markdown">🚀 上传并向量化</button>
<div v-html="results.markdown"></div>
</div>
<!-- JSON 基本 -->
<div v-show="activeSubTab === 'jsonBasic'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/basic</code><span style="font-size:12px;color:var(--sub);"></span></div>
<div class="upload-zone" @click="$refs.jsonBInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonBasic')">
<div class="icon">📋</div><p>JSON .json</p>
<input type="file" ref="jsonBInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonBasic')">
</div>
<div v-html="fileInfo.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="!fileData.jsonBasic">🚀 上传并向量化</button>
<div v-html="results.jsonBasic"></div>
</div>
<!-- JSON 按字段 -->
<div v-show="activeSubTab === 'jsonFields'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/fields</code><span style="font-size:12px;color:var(--sub);"></span></div>
<div class="upload-zone" @click="$refs.jsonFInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonFields')">
<div class="icon">🔑</div><p>JSON .json</p>
<input type="file" ref="jsonFInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonFields')">
</div>
<div v-html="fileInfo.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<input class="input" v-model="jsonFieldsStr" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="!fileData.jsonFields">🚀 上传并向量化</button>
<div v-html="results.jsonFields"></div>
</div>
<!-- JSON 按指针 -->
<div v-show="activeSubTab === 'jsonPointer'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">JSON Pointer </span></div>
<div class="upload-zone" @click="$refs.jsonPInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonPointer')">
<div class="icon">📍</div><p>JSON .json</p>
<input type="file" ref="jsonPInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonPointer')">
</div>
<div v-html="fileInfo.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<input class="input" v-model="jsonPointerStr" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="!fileData.jsonPointer">🚀 上传并向量化</button>
<div v-html="results.jsonPointer"></div>
</div>
</div>
`,
setup() {
const activeSubTab = ref('file')
const uploadCategory = ref('')
const uploadTags = ref('')
const stringTitle = ref('')
const stringContent = ref('')
const jsonFieldsStr = ref('')
const jsonPointerStr = ref('')
const fileData = reactive({
file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null
})
const fileInfo = reactive({
file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
})
const results = reactive({
file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
})
const subTabs = [
{ key: 'file', icon: '📎', label: '通用文件' },
{ key: 'string', icon: '📝', label: '文本内容' },
{ key: 'markdown', icon: '📑', label: 'Markdown' },
{ key: 'jsonBasic', icon: '📋', label: 'JSON 基本' },
{ key: 'jsonFields', icon: '🔑', label: 'JSON 按字段' },
{ key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' }
]
function handleFileSelect(event, type) {
const input = event.target
if (!input.files || input.files.length === 0) return
fileData[type] = Array.from(input.files)
const totalSize = fileData[type].reduce((s, f) => s + f.size, 0)
const label = fileData[type].length > 1
? `已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)}`
: `已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})`
fileInfo[type] = label
}
function handleDrop(event, type) {
event.currentTarget.classList.remove('drag-over')
const refMap = { file: 'fileInput', markdown: 'mdInput', jsonBasic: 'jsonBInput', jsonFields: 'jsonFInput', jsonPointer: 'jsonPInput' }
// 模拟文件选择
const dt = new DataTransfer()
for (const f of event.dataTransfer.files) dt.items.add(f)
// 通过 handleFileSelect 处理
const fakeEvent = { target: { files: dt.files } }
handleFileSelect(fakeEvent, type)
}
async function doUpload(type) {
const catId = uploadCategory.value
const tagsStr = uploadTags.value.trim()
// 文本内容上传
if (type === 'string') {
if (!stringContent.value.trim()) {
toast('请输入文本内容', 'error')
return
}
try {
const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30)
const json = await uploadString(stringContent.value, title, catId || undefined, tagsStr || undefined)
if (json.success) {
results.string = `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${json.message} | 分块数:<strong>${json.data.chunkCount}</strong> | 状态:<strong>${json.data.status}</strong></div>`
toast(json.message, 'success')
store.loadCategories()
store.loadStats()
} else {
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">${json.message}</div>`
}
} catch (e) {
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">上传失败:${e.message}</div>`
toast('上传失败:' + e.message, 'error')
}
return
}
// 文件类上传
const files = fileData[type]
if (!files || files.length === 0) {
toast('请先选择文件', 'error')
return
}
let successCount = 0, failCount = 0
const resultsHtml = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
try {
const formData = new FormData()
formData.append('file', file)
if (catId) formData.append('categoryId', catId)
if (tagsStr) formData.append('tags', tagsStr)
let json
switch (type) {
case 'file':
json = await uploadFile(formData)
break
case 'markdown':
json = await uploadMarkdown(formData)
break
case 'jsonBasic':
json = await uploadJsonBasic(formData)
break
case 'jsonFields': {
if (!jsonFieldsStr.value.trim()) {
toast('请输入要提取的字段名', 'error')
return
}
json = await uploadJsonFields(formData, jsonFieldsStr.value.trim())
break
}
case 'jsonPointer': {
if (!jsonPointerStr.value.trim()) {
toast('请输入 JSON Pointer 路径', 'error')
return
}
json = await uploadJsonPointer(formData, jsonPointerStr.value.trim())
break
}
}
if (json.success) {
successCount++
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`)
} else {
failCount++
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${json.message || '错误'}`)
}
} catch (e) {
failCount++
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${e.message}`)
}
}
const summary = successCount > 0
? `上传完成:成功 ${successCount}${failCount > 0 ? `,失败 ${failCount}` : ''}`
: `全部失败(${failCount} 个文件)`
results[type] = `<div style="margin-top:8px;padding:12px;background:${successCount > 0 ? '#ecfdf5' : '#fef2f2'};border-radius:8px;font-size:13px;">${summary}<br><div style="margin-top:6px;line-height:1.8;">${resultsHtml.join('<br>')}</div></div>`
toast(summary, successCount > 0 ? 'success' : 'error')
if (successCount > 0) {
store.loadCategories()
store.loadStats()
}
}
return {
activeSubTab, uploadCategory, uploadTags, subTabs,
stringTitle, stringContent, jsonFieldsStr, jsonPointerStr,
fileData, fileInfo, results, store,
handleFileSelect, handleDrop, doUpload, formatBytes
}
}
}

80
src/main/resources/static/components/ProductPanel.js

@ -0,0 +1,80 @@
/**
* 🏷 商品信息结构化提取面板
*/
import { ref } from 'vue'
import { extractProduct } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<div class="endpoint-info">
<span class="badge badge-get">GET</span>
<code style="font-size:13px;">/ai/product_info_app/chat/sync</code>
</div>
<h2>🏷 商品信息结构化提取</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入商品描述文本AI 自动提取标题描述价格评分评论数品牌分类</p>
<textarea class="textarea" v-model="content" placeholder="请输入商品网页内容或描述文本...
例如Apple iPhone 15 Pro Max 256GB 原色钛金属售价 ¥9999京东好评率98%累计2.5+评价品牌Apple属于智能手机分类..."></textarea>
<div class="input-row" style="margin-top:12px;">
<button class="btn btn-primary" @click="extract" :disabled="isExtracting">{{ isExtracting ? '⏳ 提取中...' : '🔍 提取商品信息' }}</button>
<button class="btn btn-outline btn-sm" @click="content=''">清空</button>
<button class="btn btn-outline btn-sm" @click="fillExample">填入示例</button>
</div>
<div v-if="result" style="margin-top:16px;">
<h3>📊 提取结果</h3>
<div class="result-grid">
<div class="result-item" v-for="f in fields" :key="f.key">
<div class="label">{{ f.label }}</div>
<div class="value">{{ result[f.key] !== null && result[f.key] !== undefined ? result[f.key] : '—' }}</div>
</div>
</div>
<div class="result-json" style="margin-top:12px;">{{ jsonText }}</div>
</div>
</div>
`,
setup() {
const content = ref('')
const result = ref(null)
const jsonText = ref('')
const isExtracting = ref(false)
const fields = [
{ key: 'title', label: '商品标题' },
{ key: 'description', label: '描述' },
{ key: 'price', label: '价格' },
{ key: 'rating', label: '评分' },
{ key: 'reviewCount', label: '评论数' },
{ key: 'brand', label: '品牌' },
{ key: 'category', label: '分类' }
]
function fillExample() {
content.value = 'Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类'
}
async function extract() {
if (!content.value.trim()) {
toast('请输入商品描述内容', 'error')
return
}
isExtracting.value = true
try {
const text = await extractProduct(content.value)
const data = JSON.parse(text)
result.value = data
jsonText.value = JSON.stringify(data, null, 2)
toast('商品信息提取成功!', 'success')
} catch (e) {
toast('提取失败:' + e.message, 'error')
} finally {
isExtracting.value = false
}
}
return { content, result, jsonText, isExtracting, fields, extract, fillExample }
}
}

136
src/main/resources/static/css/main.css

@ -0,0 +1,136 @@
/* ==================== CSS 变量 ==================== */
:root { --bg:#f0f2f5; --card:#fff; --primary:#6366f1; --primary2:#8b5cf6; --success:#10b981; --warn:#f59e0b; --danger:#ef4444; --text:#1f2937; --sub:#6b7280; --border:#e5e7eb; --radius:10px; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
/* ==================== 顶部导航 ==================== */
.topbar { background:linear-gradient(135deg, var(--primary) 0%, var(--primary2) 100%); color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:100; }
.topbar .logo { font-size:20px; font-weight:700; }
.topbar .ver { font-size:12px; opacity:.7; margin-left:4px; }
.topbar .links { margin-left:auto; display:flex; gap:12px; }
.topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; }
.topbar .links a:hover { opacity:1; }
/* ==================== Tab 导航 ==================== */
.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; position:sticky; top:56px; z-index:99; }
.tab-btn { padding:14px 24px; border:none; background:none; font-size:14px; cursor:pointer; border-bottom:3px solid transparent; color:var(--sub); transition:all .2s; font-family:inherit; display:flex; align-items:center; gap:6px; }
.tab-btn:hover { color:var(--text); }
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; }
.tab-icon { font-size:18px; }
/* ==================== 内容区 ==================== */
.main { max-width:1400px; margin:0 auto; padding:20px; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
/* ==================== 卡片 ==================== */
.card { background:var(--card); border-radius:var(--radius); padding:20px; margin-bottom:16px; border:1px solid var(--border); }
.card h2 { font-size:16px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.card h3 { font-size:14px; margin-bottom:8px; color:var(--sub); }
/* ==================== 表单 ==================== */
.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; }
.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); }
.input { flex:1; min-width:200px; }
.textarea { width:100%; resize:vertical; min-height:120px; }
.select { min-width:160px; }
.input-sm { width:120px; flex:none; }
.btn { padding:10px 20px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-family:inherit; font-weight:500; transition:all .2s; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
.btn:disabled { opacity:.5; cursor:not-allowed; }
.btn-primary { background:var(--primary); color:#fff; }
.btn-primary:hover:not(:disabled) { background:#4f46e5; }
.btn-purple { background:var(--primary2); color:#fff; }
.btn-purple:hover:not(:disabled) { background:#7c3aed; }
.btn-success { background:var(--success); color:#fff; }
.btn-outline { background:#fff; color:var(--primary); border:1px solid var(--primary); }
.btn-sm { padding:6px 12px; font-size:12px; }
.btn-danger { background:var(--danger); color:#fff; }
.btn-warn { background:var(--warn); color:#fff; }
/* ==================== 消息区 ==================== */
.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fafbfc; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; }
.msg { display:flex; gap:10px; max-width:85%; animation: fadeIn .3s; }
.msg.user { align-self:flex-end; flex-direction:row-reverse; }
.msg.assistant { align-self:flex-start; }
.msg-avatar { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; flex-shrink:0; }
.msg.user .msg-avatar { background:var(--primary); color:#fff; }
.msg.assistant .msg-avatar { background:var(--success); color:#fff; }
.msg-bubble { padding:10px 14px; border-radius:12px; line-height:1.6; font-size:14px; word-break:break-word; white-space:pre-wrap; }
.msg.user .msg-bubble { background:var(--primary); color:#fff; border-bottom-right-radius:4px; }
.msg.assistant .msg-bubble { background:#fff; border:1px solid var(--border); border-bottom-left-radius:4px; }
.msg.streaming .msg-bubble { border-color:var(--primary); box-shadow:0 0 0 1px var(--primary); }
/* ==================== 商品信息展示 ==================== */
.result-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:12px; margin-top:12px; }
.result-item { background:#f9fafb; padding:12px 16px; border-radius:8px; border:1px solid var(--border); }
.result-item .label { font-size:12px; color:var(--sub); margin-bottom:4px; }
.result-item .value { font-size:14px; font-weight:500; }
.result-json { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:8px; font-family:'Fira Code',monospace; font-size:13px; white-space:pre-wrap; overflow-x:auto; }
/* ==================== 文件上传区 ==================== */
.upload-zone { border:2px dashed var(--border); border-radius:var(--radius); padding:32px; text-align:center; transition:all .2s; cursor:pointer; margin-bottom:12px; }
.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#eef2ff; }
.upload-zone p { color:var(--sub); }
.upload-zone .icon { font-size:40px; margin-bottom:8px; }
/* ==================== Toast ==================== */
.toast-container { position:fixed; top:70px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; }
.toast { padding:10px 18px; border-radius:8px; color:#fff; font-size:13px; animation:slideIn .3s; box-shadow:0 4px 12px rgba(0,0,0,.15); }
.toast.success { background:var(--success); }
.toast.error { background:var(--danger); }
.toast.info { background:var(--primary); }
@keyframes slideIn { from{opacity:0;transform:translateX(100px);} to{opacity:1;transform:translateX(0);} }
/* ==================== 流式结果比较 ==================== */
.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.stream-compare .card { margin-bottom:0; }
/* ==================== 表格样式 ==================== */
.data-table { width:100%; border-collapse:collapse; font-size:13px; }
.data-table th { background:#f9fafb; padding:10px 12px; text-align:left; font-weight:600; border-bottom:2px solid var(--border); white-space:nowrap; }
.data-table td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
.data-table tr:hover { background:#f9fafb; }
.data-table .status-ready { color:var(--success); font-weight:600; }
.data-table .status-processing { color:var(--warn); font-weight:600; }
.data-table .status-failed { color:var(--danger); font-weight:600; }
/* ==================== 分页 ==================== */
.pagination { display:flex; gap:4px; justify-content:center; margin-top:12px; }
.pagination button { padding:6px 12px; border:1px solid var(--border); background:#fff; border-radius:6px; cursor:pointer; font-size:12px; }
.pagination button:hover:not(:disabled) { background:var(--primary); color:#fff; border-color:var(--primary); }
.pagination button:disabled { opacity:.5; cursor:not-allowed; }
.pagination button.active { background:var(--primary); color:#fff; border-color:var(--primary); }
/* ==================== 统计卡片 ==================== */
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
.stat-card { background:#f9fafb; border-radius:8px; padding:16px; text-align:center; border:1px solid var(--border); }
.stat-card .number { font-size:28px; font-weight:700; color:var(--primary); }
.stat-card .label { font-size:12px; color:var(--sub); margin-top:4px; }
/* ==================== 弹窗 ==================== */
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; }
.modal-overlay.active { display:flex; }
.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; }
.modal-box h2 { margin-bottom:16px; font-size:18px; }
.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; }
.modal-close:hover { color:var(--text); }
/* ==================== 分类标签 ==================== */
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#eef2ff; color:var(--primary); }
/* ==================== 搜索结果 ==================== */
.search-result { background:#f9fafb; border-radius:8px; padding:12px; margin-bottom:8px; border:1px solid var(--border); }
.search-result .score { font-size:12px; color:var(--success); font-weight:600; }
.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; }
.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; }
/* ==================== 端点标签 ==================== */
.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; }
.badge-get { background:#dbeafe; color:#1d4ed8; }
.badge-post { background:#fef3c7; color:#b45309; }
.endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
/* ==================== 响应式 ==================== */
@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; } }

1098
src/main/resources/static/frontend.html
File diff suppressed because it is too large
View File

20
src/main/resources/static/index.html

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 智能客服系统 - Support Bot</title>
<link rel="stylesheet" href="css/main.css">
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js"
}
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

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

@ -0,0 +1,259 @@
/**
* 统一 API 请求层
* 封装所有后端接口调用组件只需调用函数处理业务逻辑
*/
import { API_BASE } from './utils.js'
// ==================== 通用请求 ====================
/**
* GET 请求返回 JSON
*/
async function getJSON(path) {
const res = await fetch(API_BASE + path)
return res.json()
}
/**
* POST JSON 请求返回 JSON
*/
async function postJSON(path, body) {
const res = await fetch(API_BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
return res.json()
}
/**
* DELETE 请求返回 JSON
*/
async function deleteJSON(path) {
const res = await fetch(API_BASE + path, { method: 'DELETE' })
return res.json()
}
/**
* PUT 请求返回 JSON
*/
async function putJSON(path, params) {
let url = API_BASE + path
if (params) {
const qs = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
if (qs) url += (url.includes('?') ? '&' : '?') + qs
}
const res = await fetch(url, { method: 'PUT' })
return res.json()
}
/**
* POST FormData 请求返回 JSON
*/
async function postForm(path, formData) {
const res = await fetch(API_BASE + path, {
method: 'POST',
body: formData
})
return res.json()
}
// ==================== AI 对话 ====================
/**
* 同步对话
*/
export function chatSync(message, chatId) {
return fetch(API_BASE + `/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`)
.then(res => res.text())
}
/**
* RAG 同步对话
*/
export function chatRagSync(message, chatId, strategy) {
return fetch(API_BASE + `/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`)
.then(res => res.text())
}
/**
* 获取 SSE 流式对话 URL
* @param {'sse'|'sse2'|'sse3'} mode
* @returns {string}
*/
export function chatSSEUrl(message, chatId, mode) {
const pathMap = {
sse: '/ai/assistant_app/chat/sse',
sse2: '/ai/assistant_app/chat/server_sent_event',
sse3: '/ai/assistant_app/chat/sse_emitter'
}
return API_BASE + `${pathMap[mode]}?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
}
// ==================== 商品信息提取 ====================
/**
* 提取商品信息
*/
export function extractProduct(content) {
return fetch(API_BASE + `/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`)
.then(res => res.text())
}
// ==================== 文档管理 ====================
/**
* 文档列表分页 + 过滤
*/
export function listDocuments(page = 1, size = 10, categoryId, status) {
let path = `/document/list?page=${page}&size=${size}`
if (categoryId) path += `&categoryId=${categoryId}`
if (status) path += `&status=${status}`
return getJSON(path)
}
/**
* 文档详情
*/
export function getDocumentDetail(id) {
return getJSON(`/document/${id}`)
}
/**
* 文档分块列表
*/
export function getDocumentChunks(id) {
return getJSON(`/document/${id}/chunks`)
}
/**
* 删除文档
*/
export function deleteDocument(id) {
return deleteJSON(`/document/${id}`)
}
/**
* 更新文档元信息
*/
export function updateDocument(id, title, categoryId, tags) {
return putJSON(`/document/${id}`, { title, categoryId, tags })
}
/**
* 重新处理文档
*/
export function reprocessDocument(id) {
return putJSON(`/document/${id}/reprocess`)
}
// ==================== 语义搜索 ====================
/**
* 语义搜索
*/
export function searchDocuments(query, topK, similarityThreshold, categoryId) {
const body = { query, topK, similarityThreshold }
if (categoryId) body.categoryId = categoryId
return postJSON('/document/search', body)
}
// ==================== 统计 ====================
/**
* 知识库统计
*/
export function getStats() {
return getJSON('/document/stats')
}
// ==================== 上传 ====================
/**
* 上传普通文件
*/
export function uploadFile(formData) {
return postForm('/upload/file', formData)
}
/**
* 上传文本内容
*/
export function uploadString(content, title, categoryId, tags) {
let url = `/upload/string?title=${encodeURIComponent(title)}`
if (categoryId) url += `&categoryId=${categoryId}`
if (tags) url += `&tags=${encodeURIComponent(tags)}`
return fetch(API_BASE + url, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: content
}).then(res => res.json())
}
/**
* 上传 Markdown
*/
export function uploadMarkdown(formData) {
return postForm('/upload/markdown', formData)
}
/**
* 上传 JSON基本方式
*/
export function uploadJsonBasic(formData) {
return postForm('/upload/json/basic', formData)
}
/**
* 上传 JSON按字段提取
*/
export function uploadJsonFields(formData, fields) {
return postForm(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData)
}
/**
* 上传 JSON按指针拆分
*/
export function uploadJsonPointer(formData, pointer) {
return postForm(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData)
}
// ==================== 分类 ====================
/**
* 分类树
*/
export function getCategoryTree() {
return getJSON('/category/tree')
}
/**
* 分类列表
*/
export function getCategoryList() {
return getJSON('/category/list')
}
/**
* 创建分类
*/
export function createCategory(data) {
return postJSON('/category', data)
}
/**
* 更新分类
*/
export function updateCategory(id, data) {
return putJSON(`/category/${id}`, data)
}
/**
* 删除分类
*/
export function deleteCategory(id) {
return deleteJSON(`/category/${id}`)
}

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

@ -0,0 +1,95 @@
/**
* Vue 应用入口
* 组合所有组件管理 Tab 切换和顶层布局
*/
import { createApp, ref } from 'vue'
import { store } from './store.js'
// 导入组件
import ChatPanel from '../components/ChatPanel.js'
import ProductPanel from '../components/ProductPanel.js'
import DocStats from '../components/DocStats.js'
import DocSearch from '../components/DocSearch.js'
import CategoryManager from '../components/CategoryManager.js'
import DocList from '../components/DocList.js'
import DocUpload from '../components/DocUpload.js'
import DocDetail from '../components/DocDetail.js'
const app = createApp({
setup() {
const activeTab = ref('chat')
// 切换 Tab,进入知识库 Tab 时自动加载数据
function switchTab(tab) {
activeTab.value = tab
if (tab === 'document') {
store.loadCategories()
store.loadStats()
}
}
return { activeTab, switchTab, store }
},
template: `
<!-- 顶部导航 -->
<div class="topbar">
<span class="logo">🤖 Support Bot</span><span class="ver">AI </span>
<span class="links">
<a href="/doc.html" target="_blank">📖 API 文档</a>
</span>
</div>
<!-- Tab 导航 -->
<div class="tabs">
<button :class="['tab-btn', activeTab === 'chat' ? 'active' : '']" @click="switchTab('chat')">
<span class="tab-icon">💬</span>
</button>
<button :class="['tab-btn', activeTab === 'product' ? 'active' : '']" @click="switchTab('product')">
<span class="tab-icon">🏷</span>
</button>
<button :class="['tab-btn', activeTab === 'document' ? 'active' : '']" @click="switchTab('document')">
<span class="tab-icon">📄</span>
</button>
</div>
<!-- 内容区 -->
<div class="main">
<!-- Tab 1: 智能客服对话 -->
<div v-if="activeTab === 'chat'" style="animation: fadeIn .3s ease;">
<chat-panel></chat-panel>
</div>
<!-- Tab 2: 商品信息提取 -->
<div v-if="activeTab === 'product'" style="animation: fadeIn .3s ease;">
<product-panel></product-panel>
</div>
<!-- Tab 3: 知识库文档管理 -->
<div v-if="activeTab === 'document'" style="animation: fadeIn .3s ease;">
<doc-stats></doc-stats>
<doc-search></doc-search>
<category-manager></category-manager>
<doc-list></doc-list>
<doc-upload></doc-upload>
</div>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
</div>
<!-- Toast 容器 -->
<div class="toast-container" id="toasts"></div>
`
})
// 注册组件
app.component('chat-panel', ChatPanel)
app.component('product-panel', ProductPanel)
app.component('doc-stats', DocStats)
app.component('doc-search', DocSearch)
app.component('category-manager', CategoryManager)
app.component('doc-list', DocList)
app.component('doc-upload', DocUpload)
app.component('doc-detail', DocDetail)
app.mount('#app')

86
src/main/resources/static/js/store.js

@ -0,0 +1,86 @@
/**
* 共享响应式状态
* 跨组件共享的分类数据统计数据弹窗状态
*/
import { reactive } from 'vue'
import * as api from './api.js'
export const store = reactive({
// ==================== 分类数据 ====================
categories: [],
categoryMap: {},
/**
* 加载分类列表同步更新 categoryMap
*/
async loadCategories() {
try {
const json = await api.getCategoryList()
if (json.success) {
this.categories = json.data || []
this.categoryMap = {}
this.categories.forEach(c => { this.categoryMap[c.id] = c.name })
}
} catch (e) {
console.error('加载分类失败', e)
}
},
/**
* 获取分类名称
*/
getCategoryName(categoryId) {
return this.categoryMap[categoryId] || (categoryId && categoryId !== '0' ? '未知' : '未分类')
},
// ==================== 统计数据 ====================
stats: null,
/**
* 加载统计数据
*/
async loadStats() {
try {
const json = await api.getStats()
if (json.success) {
this.stats = json.data
}
} catch (e) {
console.error('加载统计失败', e)
}
},
// ==================== 文档详情弹窗 ====================
detailModal: {
visible: false,
doc: null,
chunks: []
},
/**
* 打开文档详情弹窗
*/
async openDetail(docId) {
try {
const [detailRes, chunksRes] = await Promise.all([
api.getDocumentDetail(docId),
api.getDocumentChunks(docId)
])
if (!detailRes.success) return
this.detailModal.doc = detailRes.data
this.detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : []
this.detailModal.visible = true
} catch (e) {
console.error('加载文档详情失败', e)
}
},
/**
* 关闭文档详情弹窗
*/
closeDetail() {
this.detailModal.visible = false
this.detailModal.doc = null
this.detailModal.chunks = []
}
})

99
src/main/resources/static/js/utils.js

@ -0,0 +1,99 @@
/**
* 工具函数模块
* 提供 Toast 提示格式化SSE 流式读取等通用能力
*/
// API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端
export const API_BASE = ''
// ==================== Toast 提示 ====================
let _toastContainer = null
/**
* 获取或创建 Toast 容器
*/
function getToastContainer() {
if (!_toastContainer) {
_toastContainer = document.getElementById('toasts')
}
return _toastContainer
}
/**
* 显示 Toast 提示
* @param {string} msg 消息内容
* @param {'info'|'success'|'error'} type 类型
*/
export function toast(msg, type = 'info') {
const container = getToastContainer()
if (!container) return
const el = document.createElement('div')
el.className = 'toast ' + type
el.textContent = msg
container.appendChild(el)
setTimeout(() => {
el.style.opacity = '0'
el.style.transition = 'opacity .3s'
setTimeout(() => el.remove(), 300)
}, 2500)
}
// ==================== 格式化工具 ====================
/**
* 格式化文件大小
* @param {number} b 字节数
* @returns {string}
*/
export function formatBytes(b) {
return b < 1024 ? b + ' B'
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
: (b / 1048576).toFixed(1) + ' MB'
}
/**
* 格式化日期
* @param {string|Date} d 日期
* @returns {string}
*/
export function formatDate(d) {
if (!d) return '-'
const date = new Date(d)
return date.toLocaleString('zh-CN')
}
// ==================== SSE 流式读取 ====================
/**
* 通用 SSE 流式读取
* 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口
*
* @param {string} url 请求地址
* @param {function(string): void} onChunk 每收到一段文本的回调
* @param {function(): void} onDone 流结束的回调
*/
export async function readSSEStream(url, onChunk, onDone) {
const res = await fetch(url)
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (data && data !== '[DONE]') onChunk(data)
} else if (line.trim() && !line.startsWith(':')) {
// Flux<String> 模式,非 SSE 标准格式,直接作为内容
onChunk(line)
}
}
}
if (onDone) onDone()
}
Loading…
Cancel
Save