From cff087fad073d8c721675d9607410360a9e03ea9 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Tue, 23 Jun 2026 09:50:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E6=9C=9F-=E5=89=8D=E7=AB=AF=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 13 +- README.md | 31 +- chat.html | 178 --- frontend.html | 1098 ----------------- src/main/resources/static/chat.html | 178 --- .../static/components/CategoryManager.js | 76 ++ .../resources/static/components/ChatPanel.js | 140 +++ .../resources/static/components/DocDetail.js | 61 + .../resources/static/components/DocList.js | 145 +++ .../resources/static/components/DocSearch.js | 71 ++ .../resources/static/components/DocStats.js | 39 + .../resources/static/components/DocUpload.js | 264 ++++ .../static/components/ProductPanel.js | 80 ++ src/main/resources/static/css/main.css | 136 ++ src/main/resources/static/frontend.html | 1098 ----------------- src/main/resources/static/index.html | 20 + src/main/resources/static/js/api.js | 259 ++++ src/main/resources/static/js/app.js | 95 ++ src/main/resources/static/js/store.js | 86 ++ src/main/resources/static/js/utils.js | 99 ++ 20 files changed, 1611 insertions(+), 2556 deletions(-) delete mode 100644 chat.html delete mode 100644 frontend.html delete mode 100644 src/main/resources/static/chat.html create mode 100644 src/main/resources/static/components/CategoryManager.js create mode 100644 src/main/resources/static/components/ChatPanel.js create mode 100644 src/main/resources/static/components/DocDetail.js create mode 100644 src/main/resources/static/components/DocList.js create mode 100644 src/main/resources/static/components/DocSearch.js create mode 100644 src/main/resources/static/components/DocStats.js create mode 100644 src/main/resources/static/components/DocUpload.js create mode 100644 src/main/resources/static/components/ProductPanel.js create mode 100644 src/main/resources/static/css/main.css delete mode 100644 src/main/resources/static/frontend.html create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/js/api.js create mode 100644 src/main/resources/static/js/app.js create mode 100644 src/main/resources/static/js/store.js create mode 100644 src/main/resources/static/js/utils.js diff --git a/CLAUDE.md b/CLAUDE.md index f5f67b6..2d95ee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,9 +55,20 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - `application.yml` 含 API Key,已被 `.gitignore` 排除 - 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 +## 前端架构 + +- **技术栈**: 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 路由约定 - AI 对话: `/ai/*`(`AiController`) diff --git a/README.md b/README.md index 05893a4..ad23773 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,10 @@ src/main/resources/ ├── support-bot.sql # 数据库初始化脚本 ├── knowledge-base.sql # 知识库增量迁移脚本 └── 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 结构化提取商品信息 | | 📄 知识库文档管理 | 文档上传(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. 环境准备 @@ -288,7 +313,7 @@ spring: ### 5. 访问 -- 前端管理页面:http://localhost:9090/frontend.html +- 前端管理页面:http://localhost:9090/index.html - API 文档:http://localhost:9090/doc.html ## 📋 查询优化策略 diff --git a/chat.html b/chat.html deleted file mode 100644 index 8163662..0000000 --- a/chat.html +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - AI 智能客服 - 对话窗口 - - - -
-
- 🤖 AI 智能客服基于通义千问 · 支持多轮对话 -
-
- 会话ID: --- - 📖 API文档 -
-
-
-
🤖
-
- 您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。
请问有什么可以帮您的? -
-
-
-
- - -
-
- - - - diff --git a/frontend.html b/frontend.html deleted file mode 100644 index 221f444..0000000 --- a/frontend.html +++ /dev/null @@ -1,1098 +0,0 @@ - - - - - -AI 智能客服系统 - Support Bot - - - -
- AI 智能客服系统 - - 📖 API 文档 - -
- -
- - - -
- -
- - -
-
-

💬 智能客服对话

-

基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆

- -
- - - - -
- -
- - -
- - - -
-
-
🤖
-
您好!我是电商智能客服助手。
可以帮您解答商品、订单、支付、物流和售后问题。

💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。
📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。
-
-
- -
- - -
-
-
- - -
-
-
- GET - /ai/product_info_app/chat/sync -
-

🏷️ 商品信息结构化提取

-

输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类

- - - -
- - - -
- - -
-
- - -
- - -
-

📊 知识库概览

-
-
-
文档总数
-
-
向量总数
-
-
最近上传
-
-
-
- - -
-

🔍 语义搜索测试

-

输入查询语句,测试知识库检索效果

-
- - - - -
-
-
- - -
-

🏷️ 分类管理

-
- - - - - -
-
暂无分类
-
- - -
-

📋 文档列表

-
- - - -
-
- - - - - - - - - - - - - - - -
ID标题类型状态分块数创建时间操作
点击刷新加载文档
-
- -
- - -
-

📤 文档上传

-

上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector

- - -
- - -
- - -
- - - - - - -
- - -
-
POST/upload/file(Tika 多格式解析)
-
-
📎

点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)

- -
-
- -
-
- - - - - - - - - - - - - - - -
- -
- -
- - - - -
- - - - diff --git a/src/main/resources/static/chat.html b/src/main/resources/static/chat.html deleted file mode 100644 index 8163662..0000000 --- a/src/main/resources/static/chat.html +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - AI 智能客服 - 对话窗口 - - - -
-
- 🤖 AI 智能客服基于通义千问 · 支持多轮对话 -
-
- 会话ID: --- - 📖 API文档 -
-
-
-
🤖
-
- 您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。
请问有什么可以帮您的? -
-
-
-
- - -
-
- - - - diff --git a/src/main/resources/static/components/CategoryManager.js b/src/main/resources/static/components/CategoryManager.js new file mode 100644 index 0000000..1f1f5ab --- /dev/null +++ b/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: ` +
+

🏷️ 分类管理

+
+ + + + + +
+ +
暂无分类
+
+
+ {{ c.name }} {{ c.description || '' }} + +
+
+
+ `, + 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 } + } +} diff --git a/src/main/resources/static/components/ChatPanel.js b/src/main/resources/static/components/ChatPanel.js new file mode 100644 index 0000000..cbc39fb --- /dev/null +++ b/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: ` +
+

💬 智能客服对话

+

基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆

+ +
+ + + + +
+ +
+ + +
+ +
+ 💡 当前已启用 RAG 知识库检索(策略:{{ ragStrategyNames[ragStrategy] || ragStrategy }}) +
+ +
+
+
{{ m.role === 'user' ? '👤' : '🤖' }}
+
{{ m.content }}
+
+
+ +
+ + +
+
+ `, + 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 + } + } +} diff --git a/src/main/resources/static/components/DocDetail.js b/src/main/resources/static/components/DocDetail.js new file mode 100644 index 0000000..09189d3 --- /dev/null +++ b/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: ` +
+ +
+ `, + 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 } + } +} diff --git a/src/main/resources/static/components/DocList.js b/src/main/resources/static/components/DocList.js new file mode 100644 index 0000000..c42e069 --- /dev/null +++ b/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: ` +
+

📋 文档列表

+
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID标题类型状态分块数创建时间操作
暂无文档
{{ d.id }} + {{ d.title }}
+ {{ d.sourceName || '' }} +
{{ d.fileType }}{{ d.status }}{{ d.chunkCount }}{{ formatDate(d.createTime) }} + + + +
+
+ + +
+ `, + 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 + } + } +} diff --git a/src/main/resources/static/components/DocSearch.js b/src/main/resources/static/components/DocSearch.js new file mode 100644 index 0000000..1564cb6 --- /dev/null +++ b/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: ` +
+

🔍 语义搜索测试

+

输入查询语句,测试知识库检索效果

+
+ + + + +
+ +
⏳ 搜索中...
+ +
未找到相关结果
+ +
搜索失败:{{ errorMsg }}
+ +
+
+
相似度得分: {{ r.score !== null ? (1 - r.score).toFixed(4) : '-' }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}
+
{{ r.content || '' }}
+
+
+
+ `, + 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 } + } +} diff --git a/src/main/resources/static/components/DocStats.js b/src/main/resources/static/components/DocStats.js new file mode 100644 index 0000000..1030201 --- /dev/null +++ b/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: ` +
+

📊 知识库概览

+
+
+
{{ store.stats?.totalDocuments || 0 }}
+
文档总数
+
+
+
{{ store.stats?.totalVectors || 0 }}
+
向量总数
+
+
+
{{ store.stats?.lastUploadTime ? formatDate(store.stats.lastUploadTime) : '-' }}
+
最近上传
+
+
+
{{ fileTypeDetail }}
+
+ `, + 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 } + } +} diff --git a/src/main/resources/static/components/DocUpload.js b/src/main/resources/static/components/DocUpload.js new file mode 100644 index 0000000..e2d87d4 --- /dev/null +++ b/src/main/resources/static/components/DocUpload.js @@ -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: ` +
+

📤 文档上传

+

上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector

+ + +
+ + +
+ + +
+ +
+ + +
+
POST/upload/file(Tika 多格式解析)
+
+
📎

点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)

+ +
+
+ +
+
+ + +
+
POST/upload/string(直接上传文本)
+ + + +
+
+ + +
+
POST/upload/markdown
+
+
📑

点击或拖拽上传,支持多文件(Markdown .md)

+ +
+
+ +
+
+ + +
+
POST/upload/json/basic(整体解析)
+
+
📋

点击或拖拽上传,支持多文件(JSON .json)

+ +
+
+ +
+
+ + +
+
POST/upload/json/fields(按字段名提取)
+
+
🔑

点击或拖拽上传,支持多文件(JSON .json)

+ +
+
+ + +
+
+ + +
+
POST/upload/json/pointer(JSON Pointer 路径拆分)
+
+
📍

点击或拖拽上传,支持多文件(JSON .json)

+ +
+
+ + +
+
+
+ `, + 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 + ? `已选择 ${fileData[type].length} 个文件(共 ${formatBytes(totalSize)})` + : `已选择:${fileData[type][0].name} (${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 = `
${json.message} | 分块数:${json.data.chunkCount} | 状态:${json.data.status}
` + toast(json.message, 'success') + store.loadCategories() + store.loadStats() + } else { + results.string = `
${json.message}
` + } + } catch (e) { + results.string = `
上传失败:${e.message}
` + 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(`${file.name} — ${json.data.chunkCount || 0} 分块`) + } else { + failCount++ + resultsHtml.push(`${file.name} — ${json.message || '错误'}`) + } + } catch (e) { + failCount++ + resultsHtml.push(`${file.name} — ${e.message}`) + } + } + + const summary = successCount > 0 + ? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}` + : `全部失败(${failCount} 个文件)` + results[type] = `
${summary}
${resultsHtml.join('
')}
` + 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 + } + } +} diff --git a/src/main/resources/static/components/ProductPanel.js b/src/main/resources/static/components/ProductPanel.js new file mode 100644 index 0000000..358549b --- /dev/null +++ b/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: ` +
+
+ GET + /ai/product_info_app/chat/sync +
+

🏷️ 商品信息结构化提取

+

输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类

+ + + +
+ + + +
+ +
+

📊 提取结果

+
+
+
{{ f.label }}
+
{{ result[f.key] !== null && result[f.key] !== undefined ? result[f.key] : '—' }}
+
+
+
{{ jsonText }}
+
+
+ `, + 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 } + } +} diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css new file mode 100644 index 0000000..d4b3783 --- /dev/null +++ b/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; } } diff --git a/src/main/resources/static/frontend.html b/src/main/resources/static/frontend.html deleted file mode 100644 index 221f444..0000000 --- a/src/main/resources/static/frontend.html +++ /dev/null @@ -1,1098 +0,0 @@ - - - - - -AI 智能客服系统 - Support Bot - - - -
- AI 智能客服系统 - - 📖 API 文档 - -
- -
- - - -
- -
- - -
-
-

💬 智能客服对话

-

基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆

- -
- - - - -
- -
- - -
- - - -
-
-
🤖
-
您好!我是电商智能客服助手。
可以帮您解答商品、订单、支付、物流和售后问题。

💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。
📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。
-
-
- -
- - -
-
-
- - -
-
-
- GET - /ai/product_info_app/chat/sync -
-

🏷️ 商品信息结构化提取

-

输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类

- - - -
- - - -
- - -
-
- - -
- - -
-

📊 知识库概览

-
-
-
文档总数
-
-
向量总数
-
-
最近上传
-
-
-
- - -
-

🔍 语义搜索测试

-

输入查询语句,测试知识库检索效果

-
- - - - -
-
-
- - -
-

🏷️ 分类管理

-
- - - - - -
-
暂无分类
-
- - -
-

📋 文档列表

-
- - - -
-
- - - - - - - - - - - - - - - -
ID标题类型状态分块数创建时间操作
点击刷新加载文档
-
- -
- - -
-

📤 文档上传

-

上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector

- - -
- - -
- - -
- - - - - - -
- - -
-
POST/upload/file(Tika 多格式解析)
-
-
📎

点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)

- -
-
- -
-
- - - - - - - - - - - - - - - -
- -
- -
- - - - -
- - - - diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..acd0513 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,20 @@ + + + + + + AI 智能客服系统 - Support Bot + + + + +
+ + + diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js new file mode 100644 index 0000000..3c515be --- /dev/null +++ b/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}`) +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..7c58407 --- /dev/null +++ b/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: ` + +
+ AI 智能客服系统 + + 📖 API 文档 + +
+ + +
+ + + +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ + + +
+ + +
+ ` +}) + +// 注册组件 +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') diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js new file mode 100644 index 0000000..7af67cb --- /dev/null +++ b/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 = [] + } +}) diff --git a/src/main/resources/static/js/utils.js b/src/main/resources/static/js/utils.js new file mode 100644 index 0000000..08ab623 --- /dev/null +++ b/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 / 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 模式,非 SSE 标准格式,直接作为内容 + onChunk(line) + } + } + } + if (onDone) onDone() +}