20 changed files with 1611 additions and 2556 deletions
-
13CLAUDE.md
-
31README.md
-
178chat.html
-
1098frontend.html
-
178src/main/resources/static/chat.html
-
76src/main/resources/static/components/CategoryManager.js
-
140src/main/resources/static/components/ChatPanel.js
-
61src/main/resources/static/components/DocDetail.js
-
145src/main/resources/static/components/DocList.js
-
71src/main/resources/static/components/DocSearch.js
-
39src/main/resources/static/components/DocStats.js
-
264src/main/resources/static/components/DocUpload.js
-
80src/main/resources/static/components/ProductPanel.js
-
136src/main/resources/static/css/main.css
-
1098src/main/resources/static/frontend.html
-
20src/main/resources/static/index.html
-
259src/main/resources/static/js/api.js
-
95src/main/resources/static/js/app.js
-
86src/main/resources/static/js/store.js
-
99src/main/resources/static/js/utils.js
@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
||||
} |
|
||||
|
|
||||
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
File diff suppressed because it is too large
View File
@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
||||
} |
|
||||
|
|
||||
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> |
|
||||
@ -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 } |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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()">×</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 } |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 } |
||||
|
} |
||||
|
} |
||||
@ -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 } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,264 @@ |
|||||
|
/** |
||||
|
* 📤 文档上传面板 |
||||
|
* 支持 6 种上传格式:通用文件、文本、Markdown、JSON(3种模式) |
||||
|
*/ |
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 } |
||||
|
} |
||||
|
} |
||||
@ -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
File diff suppressed because it is too large
View File
@ -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> |
||||
@ -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}`) |
||||
|
} |
||||
@ -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') |
||||
@ -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 = [] |
||||
|
} |
||||
|
}) |
||||
@ -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() |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue