feat: 新增对话测试页面和SQL实验室页面
- 在 bai-chat-index.html 中实现了对话测试功能,支持用户与AI的交互,包含消息发送、接收和显示。 - 在 sql-lab-index.html 中实现了SQL实验室功能,支持数据库表的查询和显示,包含表结构探测、SQL语句执行和结果展示。 - 添加了动态样式和交互效果,提升用户体验。
This commit is contained in:
@@ -4,3 +4,6 @@ require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`)
|
|||||||
require(`${__hooks}/bai_web_pb_hooks/pages/product-manage.js`)
|
require(`${__hooks}/bai_web_pb_hooks/pages/product-manage.js`)
|
||||||
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)
|
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)
|
||||||
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)
|
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)
|
||||||
|
require(`${__hooks}/bai_chat_alm_hooks/bai-ai-manage-main.pb.js`)
|
||||||
|
require(`${__hooks}/bai_chat_alm_hooks/bai-chat.pb.js`)
|
||||||
|
require(`${__hooks}/bai_chat_alm_hooks/bai-sql-lab.pb.js`)
|
||||||
|
|||||||
122
pocket-base/bai_chat_alm_hooks/bai-ai-manage-main.pb.js
Normal file
122
pocket-base/bai_chat_alm_hooks/bai-ai-manage-main.pb.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// ==============================================================================
|
||||||
|
// 模块: BAI 业务审计子系统 (v1.8 完整功能稳态版)
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
routerAdd("GET", "/bai-ai-manage", (c) => {
|
||||||
|
try {
|
||||||
|
const html = $template.loadFiles(__hooks + "/bai_chat_alm_hooks/views/ai-manage-index.html").render({});
|
||||||
|
const safeHtml = String(html || "")
|
||||||
|
const guardScript = '<script>(function(){var token=localStorage.getItem("pb_manage_token")||"";var isLoggedIn=localStorage.getItem("pb_manage_logged_in")==="1";if(!token||!isLoggedIn){window.location.replace("/pb/manage/login");}})()</script>'
|
||||||
|
const guardedHtml = safeHtml.indexOf("</head>") !== -1
|
||||||
|
? safeHtml.replace("</head>", guardScript + "</head>")
|
||||||
|
: guardScript + safeHtml
|
||||||
|
|
||||||
|
return c.html(200, guardedHtml);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(500, { message: "HTML 模板渲染失败: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 获取用户列表
|
||||||
|
routerAdd("GET", "/pb-api-v1/audit/users", (c) => {
|
||||||
|
try {
|
||||||
|
const info = c.requestInfo();
|
||||||
|
const days = parseInt(info.query["days"] || "0");
|
||||||
|
|
||||||
|
// 保持原有的时间过滤逻辑
|
||||||
|
let timeClause = (days > 0) ? `AND c.createdAt > ${new Date().getTime() - (days * 24 * 60 * 1000)}` : "";
|
||||||
|
|
||||||
|
// 【核心修正】SQL 采用 LEFT JOIN 确保 thread_id 为空的 API 对话不被漏掉
|
||||||
|
// 注意:这里去掉了可能引起语法错误的注释,使用了探针兼容的 SQL
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(t.slug, t.name, 'API_USER') as phone,
|
||||||
|
COUNT(c.id) as chat_count,
|
||||||
|
MAX(c.createdAt) as last_active
|
||||||
|
FROM workspace_chats c
|
||||||
|
LEFT JOIN workspace_threads t ON c.thread_id = t.id
|
||||||
|
WHERE 1=1 ${timeClause}
|
||||||
|
GROUP BY phone
|
||||||
|
ORDER BY last_active DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 保持对 3010 探针的调用
|
||||||
|
const res = $http.send({
|
||||||
|
url: "http://bai-alm-audit-api:3010/api/v1/audit/query",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ "sql": sql, "params": [] }),
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保持原有的 PB 用户姓名映射逻辑
|
||||||
|
let phoneToNameMap = {};
|
||||||
|
const pbRecords = $app.findAllRecords("tbl_auth_users");
|
||||||
|
pbRecords.forEach(r => {
|
||||||
|
phoneToNameMap[r.getString("users_phone")] = r.getString("users_name");
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (res.json.data || []).map(u => ({
|
||||||
|
...u,
|
||||||
|
name: phoneToNameMap[u.phone] || (u.phone === 'API_USER' ? '微信测试用户' : "")
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(200, { code: 200, data });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(500, { message: "Users API Error: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 获取流水记录
|
||||||
|
routerAdd("GET", "/pb-api-v1/audit/chat-list", (c) => {
|
||||||
|
try {
|
||||||
|
const info = c.requestInfo();
|
||||||
|
const phone = info.query["phone"];
|
||||||
|
const page = parseInt(info.query["page"] || "1");
|
||||||
|
const days = parseInt(info.query["days"] || "0");
|
||||||
|
const offset = (page - 1) * 50;
|
||||||
|
|
||||||
|
let timeClause = (days > 0) ? `AND c.createdAt > ${new Date().getTime() - (days * 24 * 60 * 60 * 1000)}` : "";
|
||||||
|
|
||||||
|
// 【核心修正】针对 API_USER 的特殊查询逻辑
|
||||||
|
let whereClause = "";
|
||||||
|
if (phone === "API_USER") {
|
||||||
|
whereClause = `WHERE c.thread_id IS NULL`;
|
||||||
|
} else {
|
||||||
|
whereClause = `WHERE t.slug = '${phone}' OR t.name = '${phone}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT c.id, w.name AS workspace_name, c.prompt, c.response, c.createdAt
|
||||||
|
FROM workspace_chats c
|
||||||
|
LEFT JOIN workspace_threads t ON c.thread_id = t.id
|
||||||
|
LEFT JOIN workspaces w ON c.workspaceId = w.id
|
||||||
|
${whereClause} ${timeClause}
|
||||||
|
ORDER BY c.createdAt DESC
|
||||||
|
LIMIT 50 OFFSET ${offset}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const res = $http.send({
|
||||||
|
url: "http://bai-alm-audit-api:3010/api/v1/audit/query",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ "sql": sql, "params": [] }),
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(200, { code: 200, data: res.json.data || [] });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(500, { message: "Chat List Error: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 全量导出保持不变
|
||||||
|
routerAdd("GET", "/pb-api-v1/audit/export-all", (c) => {
|
||||||
|
try {
|
||||||
|
const info = c.requestInfo();
|
||||||
|
const phone = info.query["phone"];
|
||||||
|
let whereClause = (phone === "API_USER") ? "WHERE c.thread_id IS NULL" : `WHERE t.slug = '${phone}' OR t.name = '${phone}'`;
|
||||||
|
|
||||||
|
const sql = `SELECT c.*, w.name AS workspace_name FROM workspace_chats c LEFT JOIN workspace_threads t ON c.thread_id = t.id LEFT JOIN workspaces w ON c.workspaceId = w.id ${whereClause} ORDER BY c.createdAt DESC;`;
|
||||||
|
const res = $http.send({ url: "http://bai-alm-audit-api:3010/api/v1/audit/query", method: "POST", body: JSON.stringify({ "sql": sql, "params": [] }), headers: { "Content-Type": "application/json" } });
|
||||||
|
return c.json(200, { code: 200, data: res.json.data || [] });
|
||||||
|
} catch (err) { return c.json(500, { message: err.message }); }
|
||||||
|
});
|
||||||
62
pocket-base/bai_chat_alm_hooks/bai-chat.pb.js
Normal file
62
pocket-base/bai_chat_alm_hooks/bai-chat.pb.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// ==============================================================================
|
||||||
|
// 模块: BAI 微信风聊天测试页 (v1.4 修复版)
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
// 1. 渲染页面路由
|
||||||
|
routerAdd("GET", "/bai-chat", (c) => {
|
||||||
|
try {
|
||||||
|
// 确保 views/bai-chat-index.html 路径存在
|
||||||
|
const html = $template.loadFiles(__hooks + "/bai_chat_alm_hooks/views/bai-chat-index.html").render({});
|
||||||
|
const safeHtml = String(html || "")
|
||||||
|
const guardScript = '<script>(function(){var token=localStorage.getItem("pb_manage_token")||"";var isLoggedIn=localStorage.getItem("pb_manage_logged_in")==="1";if(!token||!isLoggedIn){window.location.replace("/pb/manage/login");}})()</script>'
|
||||||
|
const guardedHtml = safeHtml.indexOf("</head>") !== -1
|
||||||
|
? safeHtml.replace("</head>", guardScript + "</head>")
|
||||||
|
: guardScript + safeHtml
|
||||||
|
return c.html(200, guardedHtml);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(500, { "message": "HTML 模板渲染失败: " + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 聊天转发路由
|
||||||
|
routerAdd("POST", "/pb-api-v1/chat/send", (c) => {
|
||||||
|
try {
|
||||||
|
const info = c.requestInfo();
|
||||||
|
// 增加安全取值逻辑
|
||||||
|
const body = info.body || {};
|
||||||
|
const userMsg = body.message || "";
|
||||||
|
|
||||||
|
if (!userMsg) {
|
||||||
|
return c.json(400, { "message": "消息内容不能为空" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 3001 端口直连 AnythingLLM
|
||||||
|
// 如果 8888 还没创建成功,API 会自动回落到默认线程,保证不报错
|
||||||
|
const res = $http.send({
|
||||||
|
url: "http://bai-anythingllm:3001/api/v1/workspace/ai/chat",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
"message": userMsg,
|
||||||
|
"mode": "chat",
|
||||||
|
"sessionId": "8888"
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer SEC082V-99D48HT-GB96EZ5-BS34AMS"
|
||||||
|
},
|
||||||
|
timeout: 120
|
||||||
|
});
|
||||||
|
|
||||||
|
// 解析返回结果
|
||||||
|
const resData = res.json || {};
|
||||||
|
const replyText = resData.textResponse || resData.content || resData.text || "AI 未能返回有效内容";
|
||||||
|
|
||||||
|
return c.json(200, {
|
||||||
|
"code": 200,
|
||||||
|
"reply": replyText
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(500, { "message": "转发请求失败: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
79
pocket-base/bai_chat_alm_hooks/bai-sql-lab.pb.js
Normal file
79
pocket-base/bai_chat_alm_hooks/bai-sql-lab.pb.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// ==============================================================================
|
||||||
|
// 模块: BAI SQL 实验室 (底层探勘工具) - 拆分独立版
|
||||||
|
// 路由: /bai-ai-sql-lab
|
||||||
|
// 注意: 绝不能引入 dynamic_model,防止引擎崩溃
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
// 1. 页面 UI 挂载 (路径更新为 views 目录)
|
||||||
|
routerAdd("GET", "/bai-ai-sql-lab", (c) => {
|
||||||
|
try {
|
||||||
|
const html = $template.loadFiles(__hooks + "/bai_chat_alm_hooks/views/sql-lab-index.html").render({});
|
||||||
|
const safeHtml = String(html || "")
|
||||||
|
const guardScript = '<script>(function(){var token=localStorage.getItem("pb_manage_token")||"";var isLoggedIn=localStorage.getItem("pb_manage_logged_in")==="1";if(!token||!isLoggedIn){window.location.replace("/pb/manage/login");}})()</script>'
|
||||||
|
const guardedHtml = safeHtml.indexOf("</head>") !== -1
|
||||||
|
? safeHtml.replace("</head>", guardScript + "</head>")
|
||||||
|
: guardScript + safeHtml
|
||||||
|
return c.html(200, guardedHtml);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(500, { "error": "Template Error: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 全链路体检探针 (保持原样,属于 SQL Lab 的基础能力)
|
||||||
|
routerAdd("GET", "/pb-api-v1/diagnostics", (c) => {
|
||||||
|
let report = { pb_status: "PocketBase OK", py_network: "WAIT", sqlite_storage: "WAIT", sqlite_version: "" };
|
||||||
|
try {
|
||||||
|
const res1 = $http.send({ url: "http://bai-alm-audit-api:3010/api/v1/audit/py-alive-check", method: "GET", timeout: 5 });
|
||||||
|
report.py_network = res1.statusCode === 200 ? "ALIVE" : "ERROR";
|
||||||
|
|
||||||
|
const res2 = $http.send({ url: "http://bai-alm-audit-api:3010/api/v1/audit/sqlite-read-check", method: "GET", timeout: 5 });
|
||||||
|
report.sqlite_storage = res2.statusCode === 200 ? "READY" : "ERROR";
|
||||||
|
|
||||||
|
if (res2.statusCode === 200 && res2.json.message) {
|
||||||
|
const match = res2.json.message.match(/版本:\s*([\d\.]+)/);
|
||||||
|
report.sqlite_version = match ? match[1] : "未知";
|
||||||
|
report.sqlite_detail = res2.json.message;
|
||||||
|
} else {
|
||||||
|
report.sqlite_detail = res2.raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(200, report);
|
||||||
|
} catch (err) {
|
||||||
|
report.py_network = "ERROR";
|
||||||
|
report.sqlite_storage = "ERROR";
|
||||||
|
report.sqlite_detail = err.message;
|
||||||
|
return c.json(500, report);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 万能 SQL 执行透传通道 (彻底修复 Body 读取方式)
|
||||||
|
routerAdd("POST", "/pb-api-v1/sql-ops", (c) => {
|
||||||
|
try {
|
||||||
|
// PB v0.23+ 官方推荐的无依赖 Body 读取方式
|
||||||
|
const reqBody = c.requestInfo().body;
|
||||||
|
const sqlStatement = reqBody.sql || "";
|
||||||
|
|
||||||
|
if (!sqlStatement) {
|
||||||
|
return c.json(400, { "message": "SQL 指令不能为空" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = $http.send({
|
||||||
|
url: "http://bai-alm-audit-api:3010/api/v1/audit/query",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ "sql": sqlStatement, "params": [] }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: 15
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return c.json(res.statusCode, {
|
||||||
|
code: res.statusCode,
|
||||||
|
message: res.json?.detail || res.raw || "探针层执行报错"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(200, { code: 200, data: res.json.data || [] });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(500, { code: 500, message: "PB穿透异常: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
464
pocket-base/bai_chat_alm_hooks/views/ai-manage-index.html
Normal file
464
pocket-base/bai_chat_alm_hooks/views/ai-manage-index.html
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>BAI 业务审计控制台</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; background-color: #0b0f1a; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
.prose-audit { font-size: 14px; line-height: 1.7; color: #64748b !important; transition: all 0.3s ease; }
|
||||||
|
.prose-audit p { margin-bottom: 0.85rem; }
|
||||||
|
.prose-audit strong { color: #94a3b8 !important; font-weight: 600; }
|
||||||
|
.prose-audit table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 1rem 0; -webkit-overflow-scrolling: touch; }
|
||||||
|
.prose-audit th, .prose-audit td { border: 1px solid rgba(100, 116, 139, 0.2); padding: 8px 12px; min-width: 80px; }
|
||||||
|
.prose-audit hr { border: 0; border-top: 1px solid #64748b; opacity: 0.3; margin: 1.2rem 0; }
|
||||||
|
.prose-audit pre { background: #020617; padding: 1rem; border-radius: 0.75rem; border: 1px solid #1e293b; color: #94a3b8; margin: 1rem 0; overflow-x: auto; }
|
||||||
|
|
||||||
|
.chat-card-container:hover .prose-audit, .chat-card-container:hover .prose-audit p { color: #94a3b8 !important; }
|
||||||
|
.chat-card-container:hover .prose-audit strong { color: #cbd5e1 !important; }
|
||||||
|
.chat-card-container:hover .response-box { background-color: rgba(15, 23, 42, 0.6) !important; border-color: rgba(255, 255, 255, 0.1) !important; }
|
||||||
|
|
||||||
|
/* 去除 details 默认的黑色三角形 */
|
||||||
|
details > summary { list-style: none; }
|
||||||
|
details > summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-slate-400 h-screen flex flex-col overflow-hidden" x-data="aiManage()">
|
||||||
|
|
||||||
|
<header class="h-14 bg-slate-950/50 backdrop-blur-md border-b border-white/5 flex items-center px-4 lg:px-6 justify-between shrink-0 z-20">
|
||||||
|
<div class="flex items-center space-x-3 lg:space-x-8">
|
||||||
|
<button x-show="activePhone && isMobile" @click="backToList()" class="lg:hidden p-2 -ml-2 text-emerald-500 hover:bg-white/5 rounded-full">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-1.5 h-5 bg-emerald-500 rounded-full"></div>
|
||||||
|
<span class="text-white font-bold text-sm lg:text-base tracking-tight uppercase">Audit <span class="hidden lg:inline text-emerald-500 font-normal">v2.2</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex bg-black/40 rounded-lg p-0.5 border border-white/5 scale-90 lg:scale-100 origin-left">
|
||||||
|
<template x-for="opt in timeOptions">
|
||||||
|
<button @click="setTimeFilter(opt.days)"
|
||||||
|
class="px-2 lg:px-4 py-1 text-[10px] lg:text-[11px] rounded-md transition-all font-medium"
|
||||||
|
:class="selectedDays === opt.days ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500'"
|
||||||
|
x-text="opt.label"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:flex items-center space-x-4 text-[11px] font-medium">
|
||||||
|
<span class="text-emerald-500 font-mono" x-text="users.length"></span>
|
||||||
|
<a href="/pb/bai-ai-sql-lab" target="_blank" class="text-slate-500 hover:text-emerald-400 border border-white/10 px-3 py-1 rounded transition-all">TERMINAL</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 flex overflow-hidden relative">
|
||||||
|
<aside class="absolute lg:relative z-10 w-full lg:w-80 h-full bg-[#06080f] border-r border-white/5 flex flex-col shrink-0 transition-transform duration-300"
|
||||||
|
:class="isMobile && activePhone && !showSidebar ? '-translate-x-full' : 'translate-x-0'">
|
||||||
|
<div class="p-3 bg-black/20 flex space-x-2 border-b border-white/5">
|
||||||
|
<input type="text" x-model="searchQuery" placeholder="Quick search..."
|
||||||
|
class="flex-1 bg-slate-900/50 border border-white/5 rounded-lg px-3 py-2 text-xs text-slate-200 outline-none">
|
||||||
|
<button @click="fetchUsers()" class="p-2 hover:bg-white/5 rounded-lg text-slate-500">
|
||||||
|
<svg class="w-4 h-4" :class="{'animate-spin text-emerald-500': loadingUsers}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" stroke-width="2" stroke-linecap="round"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
|
<template x-for="user in filteredUsers" :key="user.phone">
|
||||||
|
<div @click="selectUser(user)"
|
||||||
|
class="p-4 rounded-xl mb-1 cursor-pointer transition-all border border-transparent"
|
||||||
|
:class="activePhone === user.phone ? 'bg-emerald-500/5 border-emerald-500/20' : 'hover:bg-white/[0.03]'">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex flex-col flex-1 truncate pr-2">
|
||||||
|
<span class="text-[15px] truncate" :class="user.name ? 'text-slate-100 font-semibold' : 'text-slate-600 font-normal'" x-text="user.name || '-'"></span>
|
||||||
|
<span class="text-slate-500 text-[11px] mt-1 font-mono tracking-wider" x-text="user.phone"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end shrink-0">
|
||||||
|
<span class="text-[14px] font-bold font-mono" :class="activePhone === user.phone ? 'text-emerald-500' : 'text-slate-400'" x-text="user.chat_count"></span>
|
||||||
|
<span class="text-[10px] text-slate-600 mt-0.5 font-mono" x-text="formatDateShort(user.last_active)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-1 flex flex-col bg-[#0b0f1a] overflow-hidden relative z-0"
|
||||||
|
:class="isMobile && (!activePhone || showSidebar) ? 'hidden' : 'flex'">
|
||||||
|
<template x-if="activePhone">
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div class="px-4 lg:px-6 py-3 lg:py-4 bg-slate-950/30 border-b border-white/5 flex justify-between items-center shrink-0">
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-baseline lg:space-x-3">
|
||||||
|
<h2 class="text-lg lg:text-xl tracking-tight truncate max-w-[150px] lg:max-w-none" :class="activeName !== '-' ? 'text-white font-bold' : 'text-slate-600'" x-text="activeName"></h2>
|
||||||
|
<span class="text-slate-500 text-[10px] font-mono lg:bg-slate-900 lg:px-3 lg:py-0.5 lg:rounded-full" x-text="activePhone"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button @click="fetchChats()" class="p-2 hover:bg-white/5 rounded-xl">
|
||||||
|
<svg class="w-5 h-5" :class="{'animate-spin text-emerald-500': loadingChats}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" stroke-width="2" stroke-linecap="round"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="downloadCSV()" class="p-2 lg:px-4 lg:py-1.5 lg:bg-emerald-600 text-emerald-500 lg:text-white rounded-xl lg:text-xs font-bold transition-all">
|
||||||
|
<span class="hidden lg:inline">EXPORT</span>
|
||||||
|
<svg class="w-5 h-5 lg:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 lg:p-6 space-y-6 lg:space-y-8 bg-[#0b0f1a]">
|
||||||
|
<template x-for="chat in chats" :key="chat.id">
|
||||||
|
<div class="flex flex-col chat-card-container transition-all duration-300">
|
||||||
|
<div class="flex justify-between items-center mb-3 px-1">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="text-slate-600 font-mono text-[10px]">#<span x-text="chat.id"></span></span>
|
||||||
|
<span class="text-blue-400 font-bold text-[12px]" x-text="chat.workspace_name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 text-right">
|
||||||
|
<span class="text-emerald-700 font-bold text-[10px] font-mono" x-text="formatTime(chat.createdAt)"></span>
|
||||||
|
<button @click="copySingleChat(chat)" class="p-1.5 text-slate-700 hover:text-emerald-500">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" stroke-width="2"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="max-w-[90%] lg:max-w-[80%] bg-[#1a233b] border border-blue-500/10 text-slate-200 px-4 lg:px-5 py-3 rounded-2xl rounded-tr-none text-sm leading-relaxed" x-text="chat.prompt"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start" x-data="{ parsedData: parseResponse(chat.response) }">
|
||||||
|
<div class="max-w-full lg:max-w-[95%] w-full bg-slate-950/40 border border-white/5 px-4 lg:px-6 py-4 rounded-3xl rounded-tl-none response-box transition-all">
|
||||||
|
|
||||||
|
<template x-if="parsedData.think">
|
||||||
|
<details class="mb-4 group">
|
||||||
|
<summary class="cursor-pointer text-xs font-mono text-slate-500 hover:text-emerald-500 transition-colors flex items-center select-none outline-none">
|
||||||
|
<svg class="w-4 h-4 mr-1 transform transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||||
|
AI 思考逻辑 (Think Process)
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3 pl-4 border-l-2 border-slate-800 text-slate-400 prose-audit opacity-80" x-html="parsedData.think"></div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="prose-audit max-w-none w-full" x-html="parsedData.text"></div>
|
||||||
|
|
||||||
|
<template x-if="parsedData.sources && parsedData.sources.length > 0">
|
||||||
|
<details class="mt-4 group border-t border-white/5 pt-3">
|
||||||
|
<summary class="cursor-pointer text-xs font-mono text-slate-500 hover:text-blue-400 transition-colors flex items-center select-none outline-none">
|
||||||
|
<svg class="w-4 h-4 mr-1 transform transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||||
|
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||||
|
知识库召回溯源 (<span x-text="parsedData.sources.length"></span>)
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-2 lg:grid-cols-3 gap-2.5">
|
||||||
|
<template x-for="(src, index) in parsedData.sources" :key="index">
|
||||||
|
<div @click="openSourceModal(chat, src)"
|
||||||
|
class="bg-[#0f172a]/80 border border-white/5 hover:border-blue-500/40 hover:bg-blue-500/10 rounded-lg p-2.5 cursor-pointer transition-all flex flex-col justify-center h-16 shadow-sm group/card">
|
||||||
|
<div class="flex items-center space-x-1.5 w-full mb-1">
|
||||||
|
<svg class="w-3.5 h-3.5 text-blue-400/70 shrink-0 group-hover/card:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||||
|
<span class="text-[11px] font-medium text-slate-300 truncate" x-text="src.title || 'Unknown Source'" :title="src.title"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[9px] font-mono text-emerald-500/70 pl-5">
|
||||||
|
Match: <span x-text="src.score ? src.score.toFixed(4) : 'N/A'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="parsedData.metrics && parsedData.metrics.model">
|
||||||
|
<div class="mt-5 pt-3 border-t border-white/5 flex flex-wrap gap-2 lg:gap-3 text-[9px] lg:text-[10px] font-mono text-slate-500 uppercase tracking-wider">
|
||||||
|
<div class="flex items-center space-x-1.5 bg-black/40 px-2.5 py-1 rounded-md border border-white/5" title="Model Used">
|
||||||
|
<svg class="w-3 h-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||||
|
<span class="text-slate-400" x-text="parsedData.metrics.model"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="parsedData.metrics.total_tokens">
|
||||||
|
<div class="flex items-center space-x-1.5 bg-black/40 px-2.5 py-1 rounded-md border border-white/5" title="Tokens (Prompt + Completion)">
|
||||||
|
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||||
|
<span class="text-slate-600">IN:</span> <span class="text-emerald-500/80 mr-1" x-text="parsedData.metrics.prompt_tokens"></span>
|
||||||
|
<span class="text-slate-600">OUT:</span> <span class="text-blue-400/80 mr-1" x-text="parsedData.metrics.completion_tokens"></span>
|
||||||
|
<span class="text-slate-600">TOT:</span> <span class="text-purple-400/80" x-text="parsedData.metrics.total_tokens"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="parsedData.metrics.duration">
|
||||||
|
<div class="flex items-center space-x-1.5 bg-black/40 px-2.5 py-1 rounded-md border border-white/5" title="Time taken">
|
||||||
|
<svg class="w-3 h-3 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span class="text-amber-500/80" x-text="(parsedData.metrics.duration).toFixed(2) + 's'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 lg:p-4 bg-slate-950 border-t border-white/5 flex justify-center items-center space-x-6 lg:space-x-12 shrink-0">
|
||||||
|
<button @click="changePage(navPage - 1)" :disabled="navPage <= 1" class="text-[10px] px-4 py-2 bg-slate-900 border border-white/5 rounded-xl disabled:opacity-20 font-bold uppercase">Prev</button>
|
||||||
|
<span class="text-emerald-500 font-bold font-mono text-base" x-text="navPage"></span>
|
||||||
|
<button @click="changePage(navPage + 1)" :disabled="chats.length < 50" class="text-[10px] px-4 py-2 bg-slate-900 border border-white/5 rounded-xl disabled:opacity-20 font-bold uppercase">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="!activePhone" class="flex-1 items-center justify-center opacity-10 flex flex-col">
|
||||||
|
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||||
|
<p class="text-xs uppercase tracking-widest">Select to begin</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div x-show="showSourceModal" style="display: none;" class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6" x-cloak>
|
||||||
|
<div x-show="showSourceModal" x-transition.opacity class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="closeSourceModal()"></div>
|
||||||
|
|
||||||
|
<div x-show="showSourceModal"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
class="relative bg-[#0b0f1a] border border-slate-700 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
<div class="px-5 py-4 border-b border-slate-800 bg-slate-900/80 flex justify-between items-center shrink-0">
|
||||||
|
<div class="flex items-center space-x-3 truncate pr-4">
|
||||||
|
<div class="p-2 bg-blue-500/10 rounded-lg">
|
||||||
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col truncate">
|
||||||
|
<h3 class="text-sm font-bold text-slate-200 truncate" x-text="activeSource?.title"></h3>
|
||||||
|
<span class="text-[10px] text-emerald-500 font-mono mt-0.5" x-text="'匹配度 (Score): ' + (activeSource?.score ? activeSource.score.toFixed(4) : 'N/A')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="closeSourceModal()" class="text-slate-500 hover:text-white transition-colors p-1 bg-white/5 hover:bg-white/10 rounded-lg">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-5 lg:p-6 bg-[#06080f]">
|
||||||
|
<h4 class="text-xs font-mono text-slate-500 mb-3 uppercase tracking-wider flex items-center">
|
||||||
|
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
|
||||||
|
召回的文档切片内容
|
||||||
|
</h4>
|
||||||
|
<div class="text-sm text-slate-300 leading-relaxed font-mono bg-[#0b0f1a] p-4 lg:p-5 rounded-xl border border-white/5 whitespace-pre-wrap selection:bg-blue-500/30" x-text="activeSource?.text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 py-4 border-t border-slate-800 bg-slate-900/80 flex justify-between items-center shrink-0">
|
||||||
|
<span class="text-xs text-slate-500 font-mono hidden sm:inline-block">
|
||||||
|
Workspace: <span class="text-slate-400 font-bold" x-text="activeChatForSource?.workspace_name"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex space-x-3 w-full sm:w-auto">
|
||||||
|
<button @click="closeSourceModal()" class="flex-1 sm:flex-none px-4 py-2 bg-slate-800 text-slate-300 hover:bg-slate-700 rounded-lg transition-all font-medium text-sm">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<button @click="markIssueFromModal()" class="flex-1 sm:flex-none px-4 py-2 bg-rose-500/10 text-rose-400 hover:bg-rose-500 hover:text-white border border-rose-500/20 rounded-lg transition-all flex items-center justify-center shadow-sm font-medium text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"></path></svg>
|
||||||
|
标记纠错工单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('aiManage', () => ({
|
||||||
|
users: [], chats: [], loadingUsers: false, loadingChats: false,
|
||||||
|
searchQuery: '', activePhone: '', activeName: '',
|
||||||
|
navPage: 1, selectedDays: 0,
|
||||||
|
showSidebar: true,
|
||||||
|
isMobile: window.innerWidth < 1024,
|
||||||
|
timeOptions: [{ label: '1D', days: 1 }, { label: '3D', days: 3 }, { label: '7D', days: 7 }, { label: 'ALL', days: 0 }],
|
||||||
|
|
||||||
|
// ================= 新增:溯源弹窗相关状态 =================
|
||||||
|
activeSource: null,
|
||||||
|
activeChatForSource: null,
|
||||||
|
showSourceModal: false,
|
||||||
|
|
||||||
|
openSourceModal(chat, src) {
|
||||||
|
this.activeChatForSource = chat;
|
||||||
|
this.activeSource = src;
|
||||||
|
this.showSourceModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSourceModal() {
|
||||||
|
this.showSourceModal = false;
|
||||||
|
// 延迟清空数据,等待退出动画执行完毕
|
||||||
|
setTimeout(() => {
|
||||||
|
this.activeSource = null;
|
||||||
|
this.activeChatForSource = null;
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
markIssueFromModal() {
|
||||||
|
if (!this.activeSource || !this.activeChatForSource) return;
|
||||||
|
|
||||||
|
const suggestion = prompt(`🚩 标记知识库错误\n\n来源文档:${this.activeSource.title}\n相似度得分:${this.activeSource.score ? this.activeSource.score.toFixed(4) : 'N/A'}\n\n请输入修改建议或错误说明:`);
|
||||||
|
|
||||||
|
if (suggestion) {
|
||||||
|
// 【占位】后续替换为向 tbl_kb_tickets 写入记录的 API 请求
|
||||||
|
console.log("【生成工单】", {
|
||||||
|
chat_id: this.activeChatForSource.id,
|
||||||
|
workspace: this.activeChatForSource.workspace_name,
|
||||||
|
doc_title: this.activeSource.title,
|
||||||
|
original_text: this.activeSource.text,
|
||||||
|
suggestion: suggestion
|
||||||
|
});
|
||||||
|
|
||||||
|
alert("✅ 纠错工单已生成!\n\n您可以在工单中心直接打开该 MD 文件进行编辑。");
|
||||||
|
this.closeSourceModal(); // 提交后自动关闭弹窗
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// =======================================================
|
||||||
|
|
||||||
|
get apiBase() { return (window.location.pathname.startsWith('/pb') ? '/pb' : '') + '/pb-api-v1'; },
|
||||||
|
get filteredUsers() {
|
||||||
|
const q = this.searchQuery.toLowerCase();
|
||||||
|
return this.users.filter(u => u.phone.includes(q) || (u.name && u.name.toLowerCase().includes(q)));
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.fetchUsers();
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.isMobile = window.innerWidth < 1024;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setTimeFilter(d) { this.selectedDays = d; this.navPage = 1; this.fetchUsers(); if (this.activePhone) this.fetchChats(); },
|
||||||
|
|
||||||
|
async fetchUsers() {
|
||||||
|
this.loadingUsers = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiBase}/audit/users?days=${this.selectedDays}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code === 200) this.users = json.data;
|
||||||
|
} finally { this.loadingUsers = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
selectUser(user) {
|
||||||
|
this.activePhone = user.phone;
|
||||||
|
this.activeName = user.name || '-';
|
||||||
|
this.navPage = 1;
|
||||||
|
this.showSidebar = false;
|
||||||
|
this.fetchChats();
|
||||||
|
},
|
||||||
|
|
||||||
|
backToList() {
|
||||||
|
this.showSidebar = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
changePage(p) { this.navPage = p; this.fetchChats(); },
|
||||||
|
|
||||||
|
async fetchChats() {
|
||||||
|
if (!this.activePhone) return;
|
||||||
|
this.loadingChats = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiBase}/audit/chat-list?phone=${this.activePhone}&page=${this.navPage}&days=${this.selectedDays}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code === 200) this.chats = json.data;
|
||||||
|
} finally { this.loadingChats = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
parseResponse(raw) {
|
||||||
|
let result = { text: '', think: '', metrics: {}, sources: [] };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
let rawText = parsed.text || raw;
|
||||||
|
|
||||||
|
result.metrics = parsed.metrics || {};
|
||||||
|
result.sources = parsed.sources || [];
|
||||||
|
|
||||||
|
const thinkMatch = rawText.match(/<think>([\s\S]*?)<\/think>/i);
|
||||||
|
if (thinkMatch) {
|
||||||
|
result.think = marked.parse(thinkMatch[1].trim());
|
||||||
|
rawText = rawText.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
|
||||||
|
}
|
||||||
|
result.text = marked.parse(rawText);
|
||||||
|
} catch (e) {
|
||||||
|
const thinkMatch = raw.match(/<think>([\s\S]*?)<\/think>/i);
|
||||||
|
let rawText = raw;
|
||||||
|
if (thinkMatch) {
|
||||||
|
result.think = marked.parse(thinkMatch[1].trim());
|
||||||
|
rawText = raw.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
|
||||||
|
}
|
||||||
|
result.text = marked.parse(rawText);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(ms) {
|
||||||
|
const d = new Date(parseInt(ms));
|
||||||
|
const pad = (n, l=2) => String(n).padStart(l, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDateShort(ms) {
|
||||||
|
const d = new Date(parseInt(ms));
|
||||||
|
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
copySingleChat(chat) {
|
||||||
|
let answer = "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(chat.response).text;
|
||||||
|
answer = parsed.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||||
|
} catch(e) {
|
||||||
|
answer = chat.response.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||||
|
}
|
||||||
|
const content = `[USER]: ${chat.prompt}\n\n[AI]: ${answer}`;
|
||||||
|
navigator.clipboard.writeText(content).then(() => alert("Entry copied."));
|
||||||
|
},
|
||||||
|
|
||||||
|
copyAsMarkdown() {
|
||||||
|
let md = "| ID | TIMESTAMP | WORKSPACE | PROMPT | RESPONSE |\n| --- | --- | --- | --- | --- |\n";
|
||||||
|
this.chats.forEach(c => {
|
||||||
|
let ans = "";
|
||||||
|
try {
|
||||||
|
ans = JSON.parse(c.response).text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||||
|
} catch(e) {
|
||||||
|
ans = c.response.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||||
|
}
|
||||||
|
const cleanPrompt = c.prompt.replace(/\n/g, '<br>').replace(/\|/g, '\\|');
|
||||||
|
const cleanResponse = ans.replace(/\n/g, '<br>').replace(/\|/g, '\\|');
|
||||||
|
md += `| ${c.id} | ${this.formatTime(c.createdAt)} | ${c.workspace_name} | ${cleanPrompt} | ${cleanResponse} |\n`;
|
||||||
|
});
|
||||||
|
navigator.clipboard.writeText(md).then(() => alert("Copied."));
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadCSV() {
|
||||||
|
const res = await fetch(`${this.apiBase}/audit/export-all?phone=${this.activePhone}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.data || json.data.length === 0) return alert("No data.");
|
||||||
|
const headers = Object.keys(json.data[0]);
|
||||||
|
let csv = "\uFEFF" + headers.join(",") + "\n";
|
||||||
|
json.data.forEach(row => {
|
||||||
|
const line = headers.map(h => {
|
||||||
|
let val = row[h];
|
||||||
|
if (val === null) return '""';
|
||||||
|
return '"' + String(val).replace(/"/g, '""') + '"';
|
||||||
|
});
|
||||||
|
csv += line.join(",") + "\n";
|
||||||
|
});
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `audit_${this.activePhone}.csv`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
203
pocket-base/bai_chat_alm_hooks/views/bai-chat-index.html
Normal file
203
pocket-base/bai_chat_alm_hooks/views/bai-chat-index.html
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>对话测试</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* 使用 dvh (Dynamic Viewport Height) 解决移动端工具栏遮挡高度的问题 */
|
||||||
|
:root { --app-height: 100dvh; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
|
height: var(--app-height);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 气泡尖角 */
|
||||||
|
.bubble-left::before {
|
||||||
|
content: ""; position: absolute; width: 0; height: 0;
|
||||||
|
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
|
||||||
|
border-right: 7px solid white; left: -7px; top: 12px;
|
||||||
|
}
|
||||||
|
.bubble-right::after {
|
||||||
|
content: ""; position: absolute; width: 0; height: 0;
|
||||||
|
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
|
||||||
|
border-left: 8px solid #95ec69; right: -8px; top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏滚动条但保持滚动 */
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.chat-scroll::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Markdown 内容样式 */
|
||||||
|
.prose-chat { font-size: 16px; color: #191919; line-height: 1.6; word-break: break-word; }
|
||||||
|
.prose-chat p { margin-bottom: 0.5rem; }
|
||||||
|
.prose-chat p:last-child { margin-bottom: 0; }
|
||||||
|
.prose-chat pre { background: #f8f8f8; padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; font-size: 13px; }
|
||||||
|
.prose-chat code { font-family: 'JetBrains Mono', monospace; background: rgba(0,0,0,0.05); padding: 2px 4px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
.msg-fade { animation: fadeIn 0.3s ease-out forwards; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body x-data="weChat()">
|
||||||
|
|
||||||
|
<header class="h-14 bg-[#ededed] border-b border-[#dbdbdb] flex items-center px-4 shrink-0 z-20">
|
||||||
|
<div class="flex-1 flex items-center">
|
||||||
|
<svg @click="window.history.back()" class="w-6 h-6 text-[#191919] cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||||
|
<span class="ml-2 text-[17px] font-bold text-[#191919]">对话测试</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-6 h-6 text-[#191919]" fill="currentColor" viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></svg>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="chat-container" class="chat-scroll p-4 space-y-6 bg-[#f5f5f5]">
|
||||||
|
<div class="text-center py-2">
|
||||||
|
<span class="bg-[#dadada]/60 text-white text-[11px] px-2 py-0.5 rounded-sm" x-text="chatTime"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="(msg, index) in messages" :key="index">
|
||||||
|
<div :class="msg.role === 'user' ? 'flex flex-row-reverse' : 'flex flex-row'" class="msg-fade">
|
||||||
|
<div class="w-11 h-11 rounded-md shrink-0 overflow-hidden shadow-sm" :class="msg.role === 'user' ? 'ml-3' : 'mr-3'">
|
||||||
|
<img :src="msg.role === 'user' ? '/assets/images/user-avatar.svg?v=1' : '/assets/images/ai-avatar.svg?v=1'"
|
||||||
|
class="w-full h-full bg-[#F3E5F5] object-cover"
|
||||||
|
@error="$el.src='https://api.dicebear.com/7.x/avataaars/svg?seed=Felix'">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative max-w-[80%] px-3 py-2.5 rounded-md shadow-sm border border-black/[0.02]"
|
||||||
|
:class="msg.role === 'user' ? 'bg-[#95ec69] bubble-right' : 'bg-white bubble-left'">
|
||||||
|
<div class="prose-chat" x-html="renderContent(msg.content)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div x-show="isTyping" class="flex flex-row msg-fade">
|
||||||
|
<div class="w-11 h-11 bg-white rounded-md mr-3 flex items-center justify-center text-slate-200">AI</div>
|
||||||
|
<div class="bg-white bubble-left px-4 py-3 rounded-md shadow-sm flex items-center space-x-1.5">
|
||||||
|
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce"></div>
|
||||||
|
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce [animation-delay:0.2s]"></div>
|
||||||
|
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce [animation-delay:0.4s]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-[#f7f7f7] border-t border-[#dbdbdb] px-4 pt-3 shrink-0"
|
||||||
|
style="padding-bottom: calc(env(safe-area-inset-bottom) + 15px); min-height: 85px;">
|
||||||
|
<div class="flex items-end space-x-3">
|
||||||
|
<textarea
|
||||||
|
x-model="inputMsg"
|
||||||
|
@input="autoResize($el)"
|
||||||
|
@keydown.enter.prevent="sendMessage()"
|
||||||
|
class="flex-1 bg-white border border-[#e2e2e2] rounded-lg px-4 py-2.5 text-[16px] leading-snug outline-none resize-none max-h-32 min-h-[44px] focus:ring-0 shadow-inner"
|
||||||
|
rows="1"
|
||||||
|
placeholder="请输入消息..."></textarea>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="sendMessage()"
|
||||||
|
:disabled="!inputMsg.trim() || isTyping"
|
||||||
|
class="mb-1 px-5 py-2 rounded-lg font-bold text-[15px] transition-all transform active:scale-95"
|
||||||
|
:class="inputMsg.trim() ? 'bg-[#07c160] text-white' : 'bg-[#e1e1e1] text-[#b2b2b2]'"
|
||||||
|
x-text="isTyping ? '...' : '发送'">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('weChat', () => ({
|
||||||
|
messages: [
|
||||||
|
{ role: 'ai', content: '您好!我是您的专属助理小慧。有什么可以帮到您?' }
|
||||||
|
],
|
||||||
|
inputMsg: '',
|
||||||
|
isTyping: false,
|
||||||
|
chatTime: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
|
||||||
|
autoResize(el) {
|
||||||
|
el.style.height = '44px';
|
||||||
|
el.style.height = el.scrollHeight + 'px';
|
||||||
|
},
|
||||||
|
|
||||||
|
renderContent(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
// 过滤思考过程
|
||||||
|
let cleanText = raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||||
|
// 过滤多余的推理前缀逻辑
|
||||||
|
const filtered = cleanText.replace(/^用户(再次)?要求我[\s\S]*?。/g, '').trim();
|
||||||
|
return marked.parse(filtered.length > 2 ? filtered : cleanText);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const container = document.getElementById('chat-container');
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
const msg = this.inputMsg.trim();
|
||||||
|
if (!msg || this.isTyping) return;
|
||||||
|
|
||||||
|
// 用户消息上屏
|
||||||
|
this.messages.push({ role: 'user', content: msg });
|
||||||
|
this.inputMsg = '';
|
||||||
|
const ta = document.querySelector('textarea');
|
||||||
|
if(ta) ta.style.height = '44px';
|
||||||
|
|
||||||
|
this.isTyping = true;
|
||||||
|
this.scrollToBottom();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/pb/pb-api-v1/chat/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: msg })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.code === 200) {
|
||||||
|
const fullReply = json.reply || "";
|
||||||
|
const targetIndex = this.messages.length;
|
||||||
|
// 预留 AI 气泡位置
|
||||||
|
this.messages.push({ role: 'ai', content: '' });
|
||||||
|
|
||||||
|
// 模拟前端打字机效果
|
||||||
|
let i = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (i < fullReply.length) {
|
||||||
|
this.messages[targetIndex].content += fullReply.charAt(i);
|
||||||
|
i++;
|
||||||
|
this.scrollToBottom();
|
||||||
|
} else {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, 25);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.messages.push({ role: 'ai', content: '⚠️ 抱歉,我刚才走神了,请重试。' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.messages.push({ role: 'ai', content: '❌ 信号似乎不太好,请稍后再试。' });
|
||||||
|
} finally {
|
||||||
|
this.isTyping = false;
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
356
pocket-base/bai_chat_alm_hooks/views/sql-lab-index.html
Normal file
356
pocket-base/bai_chat_alm_hooks/views/sql-lab-index.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>BAI SQL 实验室 - 数据库探勘</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
/* 针对表头拖拽改变宽度的特化样式 */
|
||||||
|
.resizable-th { resize: horizontal; overflow: hidden; min-width: 100px; padding-bottom: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-900 text-slate-300 h-screen flex flex-col font-mono" x-data="sqlLab()">
|
||||||
|
|
||||||
|
<header class="h-14 bg-slate-950 border-b border-slate-800 flex items-center px-6 justify-between shrink-0 z-10 shadow-sm">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-emerald-500 font-bold text-lg tracking-widest">BAI SQL LAB</span>
|
||||||
|
|
||||||
|
<div class="flex space-x-3 text-xs bg-slate-900 px-3 py-1.5 rounded-md border border-slate-800 items-center">
|
||||||
|
<div class="flex items-center" title="Python 探针网络状态">
|
||||||
|
<span class="w-2 h-2 rounded-full mr-2 transition-colors duration-300" :class="diag.py_network === 'ALIVE' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' : (diag.py_network === 'WAIT' ? 'bg-amber-500 animate-pulse' : 'bg-red-500')"></span>
|
||||||
|
<span class="font-bold">PYTHON</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center ml-4" title="SQLite 核心读取状态">
|
||||||
|
<span class="w-2 h-2 rounded-full mr-2 transition-colors duration-300" :class="diag.sqlite_storage === 'READY' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' : (diag.sqlite_storage === 'WAIT' ? 'bg-amber-500 animate-pulse' : 'bg-red-500')"></span>
|
||||||
|
<span class="font-bold">SQLITE <span x-show="diag.sqlite_version" x-text="'v' + diag.sqlite_version" class="text-slate-400 ml-1 font-normal"></span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-4 border-l border-slate-700 ml-3 mr-1"></div>
|
||||||
|
|
||||||
|
<button @click="runDiagnostics()" :disabled="checking" class="text-slate-400 hover:text-emerald-400 transition-colors disabled:opacity-50" title="手动刷新探针状态">
|
||||||
|
<svg class="w-4 h-4" :class="{'animate-spin text-emerald-500': checking}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-slate-500 max-w-xs truncate" :title="diag.sqlite_detail" x-text="diag.sqlite_detail"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
|
<aside class="w-[300px] bg-slate-950 border-r border-slate-800 flex flex-col shrink-0">
|
||||||
|
<div class="p-3 border-b border-slate-800 flex justify-between items-center bg-slate-900">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider">物理表清单</h3>
|
||||||
|
<span class="text-[10px] text-amber-500 mt-0.5" x-show="totalDbSize">估算总容量: <span x-text="totalDbSize"></span></span>
|
||||||
|
</div>
|
||||||
|
<button @click="fetchTables()" :disabled="loadingTables" class="text-emerald-500 hover:text-emerald-400" title="刷新表结构">
|
||||||
|
<svg class="w-4 h-4" :class="{'animate-spin': loadingTables}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||||
|
<div x-show="loadingTables" class="text-xs text-slate-500 p-2 text-center animate-pulse">正在测绘数据库地形...</div>
|
||||||
|
<template x-for="tbl in tables" :key="tbl.name">
|
||||||
|
<div
|
||||||
|
@click="loadTable(tbl.name, 1)"
|
||||||
|
class="flex p-2 rounded cursor-pointer hover:bg-slate-800 border border-transparent hover:border-slate-700 transition-all text-xs"
|
||||||
|
:class="{'bg-slate-800 border-slate-700': navTable === tbl.name}"
|
||||||
|
>
|
||||||
|
<span class="text-emerald-400 truncate w-full" :title="tbl.name" x-text="`${tbl.name} (${tbl.count})(${tbl.size})`"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-1 flex flex-col bg-slate-900 overflow-hidden relative">
|
||||||
|
<div class="p-4 border-b border-slate-800 bg-[#0d1117] shrink-0 flex flex-col shadow-inner">
|
||||||
|
<textarea
|
||||||
|
x-model="sql"
|
||||||
|
@input="navTable = ''"
|
||||||
|
class="w-full h-32 bg-transparent border-none text-emerald-400 text-sm outline-none focus:ring-0 resize-none font-mono"
|
||||||
|
placeholder="-- 输入标准的 SQLite 语句..."
|
||||||
|
@keydown.ctrl.enter="execute()"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-2 flex justify-between items-center border-t border-slate-800 pt-3">
|
||||||
|
<span class="text-[11px] text-slate-500">快捷键: <kbd class="bg-slate-800 px-1 rounded text-slate-300">Ctrl</kbd> + <kbd class="bg-slate-800 px-1 rounded text-slate-300">Enter</kbd> 执行</span>
|
||||||
|
<button
|
||||||
|
@click="execute()"
|
||||||
|
:disabled="loading"
|
||||||
|
class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-1.5 rounded font-bold text-xs disabled:opacity-50 transition-all flex items-center shadow-lg shadow-emerald-900/20"
|
||||||
|
>
|
||||||
|
<span x-show="!loading">▶ 执行 (Run)</span>
|
||||||
|
<span x-show="loading" class="animate-pulse">穿透中...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col bg-slate-950 relative">
|
||||||
|
<div class="p-4 overflow-auto flex-1 flex flex-col relative">
|
||||||
|
<div x-show="error" class="bg-red-950/50 border border-red-800 text-red-400 p-4 rounded-lg text-sm mb-4 break-words shrink-0" x-text="error"></div>
|
||||||
|
|
||||||
|
<div x-show="results.length > 0" class="flex flex-col flex-1 min-h-0">
|
||||||
|
<div class="flex justify-between items-end mb-2 shrink-0">
|
||||||
|
<span class="text-xs text-slate-500">本页返回: <span class="text-emerald-500 font-bold" x-text="results.length"></span> 行</span>
|
||||||
|
<button
|
||||||
|
@click="copyToClipboard"
|
||||||
|
class="flex items-center gap-1 px-3 py-1 text-xs font-medium text-emerald-400 bg-emerald-900/30 border border-emerald-800/50 rounded hover:bg-emerald-800/50 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
复制 Markdown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-slate-800 rounded overflow-auto bg-[#0f172a] shadow-inner flex-1 max-h-full">
|
||||||
|
<table class="min-w-full divide-y divide-slate-800 text-xs w-max">
|
||||||
|
<thead class="bg-slate-900 sticky top-0 z-10 shadow">
|
||||||
|
<tr>
|
||||||
|
<template x-for="col in Object.keys(results[0] || {})">
|
||||||
|
<th class="border-r border-slate-800 align-top bg-slate-900">
|
||||||
|
<div class="resizable-th px-3 py-2 text-left text-slate-400 font-bold uppercase tracking-wider" x-text="col"></div>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800/50">
|
||||||
|
<template x-for="(row, idx) in results" :key="idx">
|
||||||
|
<tr class="hover:bg-slate-800/70 transition-colors">
|
||||||
|
<template x-for="val in Object.values(row)">
|
||||||
|
<td class="px-3 py-1.5 text-slate-300 border-r border-slate-800/50 max-w-[500px] truncate" :title="formatValue(val)" x-text="formatValue(val)" :class="{'text-slate-500 italic': val === null}"></td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && !error && executed && results.length === 0" class="flex items-center justify-center h-full text-slate-600 text-sm italic">
|
||||||
|
SQL 穿透成功,但返回了 0 行数据。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="navTable" class="bg-slate-950 border-t border-slate-800 p-2 px-4 flex justify-between items-center shrink-0">
|
||||||
|
<span class="text-xs text-slate-500">当前表: <span class="text-emerald-400 font-bold" x-text="navTable"></span> | 第 <span class="text-white bg-slate-800 px-1.5 rounded" x-text="navPage"></span> 页</span>
|
||||||
|
<div class="flex space-x-2 text-xs">
|
||||||
|
<button @click="loadTable(navTable, navPage - 1)" :disabled="navPage <= 1" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded text-slate-300 disabled:opacity-30 transition-colors">上一页</button>
|
||||||
|
<button @click="loadTable(navTable, navPage + 1)" :disabled="results.length < 50" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded text-slate-300 disabled:opacity-30 transition-colors">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="w-56 bg-slate-950 border-l border-slate-800 p-4 flex flex-col space-y-6 overflow-y-auto shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-[11px] font-bold text-slate-500 mb-2 uppercase tracking-wider">结构探测库</h3>
|
||||||
|
<div class="space-y-1.5 text-xs">
|
||||||
|
<button @click="runQuery('SELECT name, sql FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\';')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-emerald-400 rounded transition-colors border border-slate-800">所有物理表 DDL</button>
|
||||||
|
<button @click="runQuery('PRAGMA database_list;')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-blue-400 rounded transition-colors border border-slate-800">数据库绑定列表</button>
|
||||||
|
<button @click="runQuery('PRAGMA foreign_key_list(workspace_chats);')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-purple-400 rounded transition-colors border border-slate-800">Chats 外键约束</button>
|
||||||
|
<button @click="runQuery('PRAGMA foreign_key_list(workspace_threads);')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-purple-400 rounded transition-colors border border-slate-800">Threads 外键约束</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-[11px] font-bold text-slate-500 mb-2 uppercase tracking-wider">系统变量</h3>
|
||||||
|
<div class="space-y-1.5 text-xs">
|
||||||
|
<button @click="runQuery('PRAGMA page_size;')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-amber-400 rounded transition-colors border border-slate-800">页大小 (Page Size)</button>
|
||||||
|
<button @click="runQuery('PRAGMA page_count;')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-amber-400 rounded transition-colors border border-slate-800">总页数 (Page Count)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('sqlLab', () => ({
|
||||||
|
diag: { py_network: 'WAIT', sqlite_storage: 'WAIT', sqlite_version: '', sqlite_detail: 'Connecting...' },
|
||||||
|
checking: false,
|
||||||
|
sql: 'SELECT name FROM sqlite_master WHERE type=\'table\';',
|
||||||
|
results: [],
|
||||||
|
tables: [],
|
||||||
|
totalDbSize: '',
|
||||||
|
loading: false,
|
||||||
|
loadingTables: false,
|
||||||
|
error: '',
|
||||||
|
executed: false,
|
||||||
|
|
||||||
|
// 分页状态管理
|
||||||
|
navTable: '',
|
||||||
|
navPage: 1,
|
||||||
|
|
||||||
|
get apiBase() {
|
||||||
|
return (window.location.pathname.startsWith('/pb') ? '/pb' : '') + '/pb-api-v1';
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.runDiagnostics();
|
||||||
|
this.fetchTables();
|
||||||
|
},
|
||||||
|
|
||||||
|
async runDiagnostics() {
|
||||||
|
if (this.checking) return;
|
||||||
|
this.checking = true;
|
||||||
|
this.diag.py_network = 'WAIT';
|
||||||
|
this.diag.sqlite_storage = 'WAIT';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.apiBase + '/diagnostics');
|
||||||
|
const json = await res.json();
|
||||||
|
this.diag = json;
|
||||||
|
} catch (e) {
|
||||||
|
this.diag.py_network = 'ERROR';
|
||||||
|
this.diag.sqlite_storage = 'ERROR';
|
||||||
|
this.diag.sqlite_detail = '失联: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => { this.checking = false; }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 时间与数据格式化引擎 (UTC+8 毫秒转换)
|
||||||
|
formatValue(val) {
|
||||||
|
if (val === null) return 'NULL';
|
||||||
|
|
||||||
|
// 嗅探 13 位数字 (大概范围从 2001年 到 2049年)
|
||||||
|
if (typeof val === 'number' && val > 1000000000000 && val < 2500000000000) {
|
||||||
|
const d = new Date(val);
|
||||||
|
// 强制换算到 UTC+8 抵消本地宿主机可能的时区干扰
|
||||||
|
const utcTime = d.getTime() + (d.getTimezoneOffset() * 60000);
|
||||||
|
const utc8Time = new Date(utcTime + (8 * 3600000));
|
||||||
|
|
||||||
|
const pad = (n, len=2) => String(n).padStart(len, '0');
|
||||||
|
return `${utc8Time.getFullYear()}-${pad(utc8Time.getMonth()+1)}-${pad(utc8Time.getDate())} ${pad(utc8Time.getHours())}:${pad(utc8Time.getMinutes())}:${pad(utc8Time.getSeconds())}.${pad(utc8Time.getMilliseconds(), 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof val === 'object') return JSON.stringify(val);
|
||||||
|
return String(val);
|
||||||
|
},
|
||||||
|
|
||||||
|
async runRawQuery(querySql) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.apiBase + '/sql-ops', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sql: querySql })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
return (res.status === 200 && json.code === 200) ? json.data : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
formatSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + sizes[i];
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTables() {
|
||||||
|
this.loadingTables = true;
|
||||||
|
try {
|
||||||
|
const rawTables = await this.runRawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;");
|
||||||
|
if (!rawTables || rawTables.length === 0) return;
|
||||||
|
|
||||||
|
const tableNames = rawTables.map(t => t.name);
|
||||||
|
const countQueries = tableNames.map(name => `SELECT '${name}' AS tableName, COUNT(*) AS rowCount FROM "${name}"`);
|
||||||
|
const masterCountSql = countQueries.join(' UNION ALL ');
|
||||||
|
|
||||||
|
const countsData = await this.runRawQuery(masterCountSql);
|
||||||
|
|
||||||
|
if (countsData) {
|
||||||
|
let totalBytes = 0;
|
||||||
|
this.tables = countsData.map(row => {
|
||||||
|
const count = row.rowCount;
|
||||||
|
const estBytes = count * 512;
|
||||||
|
totalBytes += estBytes;
|
||||||
|
return { name: row.tableName, count: count, size: this.formatSize(estBytes) };
|
||||||
|
});
|
||||||
|
this.totalDbSize = this.formatSize(totalBytes);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("加载表结构失败", e);
|
||||||
|
} finally {
|
||||||
|
this.loadingTables = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点击左侧表名的单表分页加载器
|
||||||
|
loadTable(tableName, page = 1) {
|
||||||
|
this.navTable = tableName;
|
||||||
|
this.navPage = page;
|
||||||
|
const offset = (page - 1) * 50;
|
||||||
|
this.sql = `SELECT * FROM "${tableName}" ORDER BY id DESC LIMIT 50 OFFSET ${offset};`;
|
||||||
|
this.execute(true); // true 代表内部调用,不清理分页状态
|
||||||
|
},
|
||||||
|
|
||||||
|
runQuery(query) {
|
||||||
|
this.navTable = ''; // 手动执行其他按钮清理分页状态
|
||||||
|
this.sql = query;
|
||||||
|
this.execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(isFromNav = false) {
|
||||||
|
if (!this.sql.trim()) return;
|
||||||
|
// 如果是编辑器手动按Ctrl+Enter或者点击执行按钮,清理当前分页绑定
|
||||||
|
if (!isFromNav) this.navTable = '';
|
||||||
|
|
||||||
|
this.loading = true; this.error = ''; this.results = []; this.executed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.apiBase + '/sql-ops', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sql: this.sql })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (res.status === 200 && json.code === 200) {
|
||||||
|
this.results = json.data;
|
||||||
|
} else {
|
||||||
|
this.error = `[探针阻断 HTTP ${res.status}] ${json.message}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = "网络异常: " + e.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copyToClipboard() {
|
||||||
|
if (!this.results || this.results.length === 0) return;
|
||||||
|
const headers = Object.keys(this.results[0]);
|
||||||
|
let mdTable = '| ' + headers.join(' | ') + ' |\n';
|
||||||
|
mdTable += '| ' + headers.map(() => '---').join(' | ') + ' |\n';
|
||||||
|
|
||||||
|
this.results.forEach(row => {
|
||||||
|
const rowData = headers.map(header => {
|
||||||
|
let cellData = row[header];
|
||||||
|
if (cellData === null) return '`NULL`';
|
||||||
|
if (typeof cellData === 'object') cellData = JSON.stringify(cellData);
|
||||||
|
return String(cellData).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
||||||
|
});
|
||||||
|
mdTable += '| ' + rowData.join(' | ') + ' |\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(mdTable).then(() => {
|
||||||
|
const btn = document.activeElement;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> 复制成功`;
|
||||||
|
btn.classList.add('text-white', 'bg-emerald-600');
|
||||||
|
setTimeout(() => { btn.innerHTML = originalText; btn.classList.remove('text-white', 'bg-emerald-600'); }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,6 +19,8 @@ routerAdd('GET', '/manage', function (e) {
|
|||||||
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; }
|
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; }
|
||||||
.hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; }
|
.hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; }
|
||||||
h1 { margin: 0 0 14px; font-size: 30px; }
|
h1 { margin: 0 0 14px; font-size: 30px; }
|
||||||
|
.module + .module { margin-top: 18px; }
|
||||||
|
.module-title { margin: 0 0 10px; font-size: 22px; color: #0f172a; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||||
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; }
|
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; }
|
||||||
.card h2 { margin: 0 0 8px; font-size: 19px; }
|
.card h2 { margin: 0 0 8px; font-size: 19px; }
|
||||||
@@ -33,6 +35,8 @@ routerAdd('GET', '/manage', function (e) {
|
|||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>管理主页</h1>
|
<h1>管理主页</h1>
|
||||||
|
<section class="module">
|
||||||
|
<h2 class="module-title">平台管理</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h2>字典管理</h2>
|
<h2>字典管理</h2>
|
||||||
@@ -51,6 +55,24 @@ routerAdd('GET', '/manage', function (e) {
|
|||||||
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
|
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="module">
|
||||||
|
<h2 class="module-title">AI 管理</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<h2>AI 审计管理</h2>
|
||||||
|
<a class="btn" href="/pb/bai-ai-manage">进入审计管理</a>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>AI 聊天测试</h2>
|
||||||
|
<a class="btn" href="/pb/bai-chat">进入聊天测试</a>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>SQL 实验室</h2>
|
||||||
|
<a class="btn" href="/pb/bai-ai-sql-lab">进入 SQL 实验室</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>
|
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -197,13 +197,14 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:10px;">
|
<div style="margin-top:10px;">
|
||||||
<label for="paramsJsonInput">批量导入 JSON</label>
|
<label for="paramsJsonInput">批量导入/导出 JSON</label>
|
||||||
<textarea id="paramsJsonInput" placeholder='示例:{"属性名":"属性值","电压":"220v"}'></textarea>
|
<textarea id="paramsJsonInput" placeholder='示例:{"属性名":"属性值","电压":"220v"}'></textarea>
|
||||||
<div class="hint">仅支持 JSON 对象格式。导入时按属性名增量合并:同名覆盖、不同名新增,不会清空当前参数表。</div>
|
<div class="hint">仅支持 JSON 对象格式。导入时按属性名增量合并:同名覆盖、不同名新增,不会清空当前参数表。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-actions" style="margin-top:10px;">
|
<div class="param-actions" style="margin-top:10px;">
|
||||||
<button class="btn btn-light" id="addParamBtn" type="button">新增参数行</button>
|
<button class="btn btn-light" id="addParamBtn" type="button">新增参数行</button>
|
||||||
<button class="btn btn-secondary" id="importParamsBtn" type="button">批量导入</button>
|
<button class="btn btn-secondary" id="importParamsBtn" type="button">批量导入</button>
|
||||||
|
<button class="btn btn-light" id="exportParamsBtn" type="button">批量导出</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full">
|
<div class="full">
|
||||||
@@ -878,6 +879,42 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportParametersToJson() {
|
||||||
|
const rows = collectParameterArray()
|
||||||
|
const exportObject = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
const name = normalizeText(rows[i].name)
|
||||||
|
if (!name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exportObject[name] = rows[i].value === null || typeof rows[i].value === 'undefined'
|
||||||
|
? ''
|
||||||
|
: String(rows[i].value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(exportObject)
|
||||||
|
if (!keys.length) {
|
||||||
|
setStatus('当前参数表为空,暂无可导出的内容。', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonText = JSON.stringify(exportObject, null, 2)
|
||||||
|
fields.paramsJsonInput.value = jsonText
|
||||||
|
|
||||||
|
let copied = false
|
||||||
|
try {
|
||||||
|
if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||||
|
await navigator.clipboard.writeText(jsonText)
|
||||||
|
copied = true
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
setStatus(copied
|
||||||
|
? '参数已批量导出,已写入导入框并复制到剪贴板。'
|
||||||
|
: '参数已批量导出到导入框,可直接复制后用于一键导入。', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeParameterRows(value) {
|
function normalizeParameterRows(value) {
|
||||||
if (value === null || typeof value === 'undefined' || value === '') {
|
if (value === null || typeof value === 'undefined' || value === '') {
|
||||||
return []
|
return []
|
||||||
@@ -1367,6 +1404,13 @@ routerAdd('GET', '/manage/product-manage', function (e) {
|
|||||||
importParametersFromJson()
|
importParametersFromJson()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const exportParamsBtn = document.getElementById('exportParamsBtn')
|
||||||
|
if (exportParamsBtn) {
|
||||||
|
exportParamsBtn.addEventListener('click', function () {
|
||||||
|
exportParametersToJson()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (fields.sort) {
|
if (fields.sort) {
|
||||||
fields.sort.addEventListener('input', function () {
|
fields.sort.addEventListener('input', function () {
|
||||||
renderSortRankHint()
|
renderSortRankHint()
|
||||||
|
|||||||
Reference in New Issue
Block a user