From a225272d1e8672a3fff07f6d5bdcc21fd9fe5fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cyuanmengy=E2=80=9D?= Date: Fri, 5 Jun 2026 17:11:56 +0800 Subject: [PATCH] data&device ready --- .DS_Store | Bin 0 -> 8196 bytes .codebuddy/settings.local.json | 5 + .workbuddy/memory/2026-05-12.md | 36 + .workbuddy/memory/2026-05-13.md | 146 + .workbuddy/memory/2026-05-14.md | 59 + .workbuddy/memory/2026-05-15.md | 22 + .workbuddy/memory/MEMORY.md | 35 + TalkingQ-H5.html | 24115 ++++++++++++++++++++++++++++++ index.html | 2168 +++ scripts/agent-dev.js | 1821 +++ scripts/agent-manage.js | 797 + scripts/api.js | 294 + scripts/app.js | 265 + scripts/badcase.js | 273 + scripts/dashboard.js | 1050 ++ scripts/device.js | 1255 ++ scripts/hotword-library.js | 333 + scripts/kb-create.js | 1467 ++ scripts/kb-detail.js | 1233 ++ scripts/knowledge-base.js | 942 ++ scripts/login.js | 188 + scripts/ota.js | 1213 ++ scripts/privacy.js | 313 + scripts/prompt-manage.js | 119 + scripts/rbac.js | 663 + scripts/session.js | 578 + scripts/settings.js | 406 + styles/main.css | 9553 ++++++++++++ test.html | 97 + 29 files changed, 49446 insertions(+) create mode 100644 .DS_Store create mode 100644 .codebuddy/settings.local.json create mode 100644 .workbuddy/memory/2026-05-12.md create mode 100644 .workbuddy/memory/2026-05-13.md create mode 100644 .workbuddy/memory/2026-05-14.md create mode 100644 .workbuddy/memory/2026-05-15.md create mode 100644 .workbuddy/memory/MEMORY.md create mode 100644 TalkingQ-H5.html create mode 100644 index.html create mode 100644 scripts/agent-dev.js create mode 100644 scripts/agent-manage.js create mode 100644 scripts/api.js create mode 100644 scripts/app.js create mode 100644 scripts/badcase.js create mode 100644 scripts/dashboard.js create mode 100644 scripts/device.js create mode 100644 scripts/hotword-library.js create mode 100644 scripts/kb-create.js create mode 100644 scripts/kb-detail.js create mode 100644 scripts/knowledge-base.js create mode 100644 scripts/login.js create mode 100644 scripts/ota.js create mode 100644 scripts/privacy.js create mode 100644 scripts/prompt-manage.js create mode 100644 scripts/rbac.js create mode 100644 scripts/session.js create mode 100644 scripts/settings.js create mode 100644 styles/main.css create mode 100644 test.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ce585bf41c8ae4f1ad3e5ad55abfffa828d2f67e GIT binary patch literal 8196 zcmeHM-HOvd6h5zeTzDeesIm!1WnVgh}Saa5^5EY2XLKT?b zLGz0u#zkEz8+y+!kinm*PcF@GnA7zPXjhJk;90j$}gSSjni8a1t9 zz%cM%G9aE0Hmbme#!#VrbfA$b05F5yvY?MVK>FAk8yZ7}(iL;6?15=lrc(^2+%awo zb6`Vbs8G2RQ|`ocWTrC|CZog8mgdA73QcPmFbu3RAaeI@Dp8j>vi3;+?j2T~E+37x z%Z)U<5pT6zkGmMR{IBN=&%S)Rl5X4kW83i{8eiVk+cYF!9_14p@qhvvk$2tWT^(N- ziP)v@vYc{{&*mHR2%uU1_a?Zu^PxoGe2)tAehb>_mw%QxGPyphje%CjV2OnVhf4|_MU{fGp_r9PjRl(ZU+`gyVPIVhY^uNok^f&y|Ng(O!!vOW1BQVU zV1Q*>POFJPM=z1dm}056Q4df>5q64HYC=0KT_1CReOMBj#~@M&la6=DyHc@dB_ Ln8q-0q73{1u9YVB literal 0 HcmV?d00001 diff --git a/.codebuddy/settings.local.json b/.codebuddy/settings.local.json new file mode 100644 index 0000000..7a6d5dd --- /dev/null +++ b/.codebuddy/settings.local.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "modern-webapp@cb_teams_marketplace": true + } +} \ No newline at end of file diff --git a/.workbuddy/memory/2026-05-12.md b/.workbuddy/memory/2026-05-12.md new file mode 100644 index 0000000..d017631 --- /dev/null +++ b/.workbuddy/memory/2026-05-12.md @@ -0,0 +1,36 @@ +# 2026-05-12 工作记录 + +## 主要变更:产品线分类 → 公司分类 + +### 用户需求 +用户认为不需要按产品线(TalkingQ Pro / TalkingQ Lite)分类,而是要按**不同公司/客户**来分类。 + +### 修改内容 + +1. **index.html** + - 侧边栏:新增"客户公司"导航,支持展开子菜单(设备管理、OTA升级) + - Topbar:租户选择器改为公司选择器 + - 新增公司数据模型和切换逻辑 + +2. **styles/main.css** + - 新增公司导航样式(company-selector、company-nav-item、company-submenu等) + - 新增公司信息提示样式(current-company-banner、all-companies-banner) + +3. **scripts/device.js**(完全重写) + - 移除产品线分类,改用公司分类 + - 设备数据增加 companyId 和 companyName 字段 + - 支持按当前选中的公司过滤设备 + +4. **scripts/ota.js**(完全重写) + - 移除产品线分类,改用公司分类 + - 每个公司有独立的固件版本列表 + - OTA任务按公司分类显示 + +5. **scripts/rbac.js**(完全重写) + - 移除产品级权限配置,改用公司级权限配置 + - Client 角色可分配到特定公司 + - Admin/Worker 可访问全部公司数据 + +### 新数据结构 +- **公司列表**:智慧星科技、未来玩具厂、童趣电子 +- **权限层级**:Admin > Worker > Client(按公司隔离) diff --git a/.workbuddy/memory/2026-05-13.md b/.workbuddy/memory/2026-05-13.md new file mode 100644 index 0000000..20f3b45 --- /dev/null +++ b/.workbuddy/memory/2026-05-13.md @@ -0,0 +1,146 @@ +# 2026-05-13 工作日志 + +## Bug修复 + +### 知识库列表页不显示问题 +- **问题**:`getPaginedData` 拼写错误,应为 `getPaginatedData`,导致 JS 报错 +- **修复**:scripts/knowledge-base.js 第103行 + +## 交互优化 + +### 全局 Tooltip 功能 +- **样式优化**:更新 styles/main.css 中 [data-tooltip] 样式 + - 添加阴影效果 `box-shadow: 0 2px 8px rgba(0,0,0,0.2)` + - 添加悬停动画 `transform: translateY(-2px)` + - 优化间距和圆角 +- **应用范围**:更新以下页面的图标按钮为 `data-tooltip` 属性 + - knowledge-base.js(列表页、返回按钮) + - agent-manage.js(应用卡片操作按钮) + - badcase.js(查看、标注、关闭按钮) + - prompt-manage.js(编辑、版本记录按钮) + - session.js(回放、标记按钮) + - privacy.js(复制、撤销按钮) + - kb-create.js(重试、删除、返回按钮) + - kb-detail.js(查看切片、Meta信息、更多操作按钮) + - agent-dev.js(编辑名称、语音输入、移除技能/插件按钮) + +--- + +## 知识库管理模块完善 + +### 完成内容 + +1. **CSS样式完善** (styles/main.css) + - 添加知识库列表页表格样式 + - 添加批量操作栏、加载状态、空状态 + - 添加知识库类型标签(文档搜索、数据查询、图片问答、音视频搜索) + - 添加文件状态徽章(已就绪、处理中、解析失败) + - 添加分页器样式 + - 添加创建向导样式(步骤指示器、内容区、底部操作栏) + - 添加知识库类型卡片选择器 + - 添加上传区域、文件列表、类目选择器 + - 添加切片配置面板、范围滑块 + - 添加详情页样式(头部信息、Tab切换、切片列表) + - 添加命中测试面板、历史记录 + - 添加Tooltip样式、Switch开关、下拉菜单 + +2. **知识库列表页** (scripts/knowledge-base.js) + - 表格形式展示知识库列表 + - 字段:名称、类型、数据量、状态、创建时间、更新时间、操作 + - 支持排序、筛选、搜索、分页 + - 支持批量选择和批量操作 + - 支持查看详情、命中测试、编辑、删除 + - 支持重建索引、导出 + - 空状态、加载状态展示 + - 添加知识库弹窗(列表+创建入口) + +3. **创建知识库向导** (scripts/kb-create.js) + - 3步骤流程(基础信息→数据选择→索引设置) + - Step 1:知识库名称(必填)、描述、类型选择(带Tooltip) + - Step 2:数据连接器说明、3种数据来源(文件上传/选择类目/选择文件) + - 文件上传:拖拽上传、进度、状态、删除、重试 + - 支持按知识库类型限制文件格式和大小 + - 解析方式配置(继承目录/默认/自定义) + - 自动同步开关 + - Step 3:6种切片方式(智能/按长度/按页/按标题/按正则/按符号) + - 各切片方式对应参数配置 + - 高级参数折叠面板(向量模型、排序模型、排序模式、检索参数) + - 所有专业字段带Tooltip解释 + - 支持保存草稿、离开确认 + - 必填校验 + +4. **知识库详情页** (scripts/kb-detail.js) + - 返回按钮、面包屑导航 + - 头部信息(名称、描述、类型、ID、数据量、更新时间、标签) + - 操作按钮(命中测试、编辑、更多菜单) + - Tab切换(文件管理、标签管理) + - 文件管理Tab: + - 文件统计卡片(总数、已解析、解析中、失败) + - 批量操作栏 + - 完整文件列表(文件名、格式、大小、数据量、状态、所属类目、切片数、索引时间) + - 支持查看切片、Meta信息、重新解析、删除 + - 解析失败显示错误原因 + - 标签管理Tab: + - 标签列表(新增、删除) + - 批量操作 + - 上传数据入口 + - 命中测试弹窗: + - 测试参数配置 + - 快捷问题标签 + - 历史召回记录 + +### 技术要点 +- 所有专业字段带Tooltip解释 +- 支持返回上一级、取消操作、弹窗确认 +- 保存草稿(localStorage) +- 离开页面二次确认 +- 必填校验 +- 空状态、加载状态、解析中状态、失败状态 +- 企业级SaaS风格,简洁现代 + +## 知识库管理前端更新完成 + +### 已更新的文件 +1. `styles/main.css` - 完整的企业级样式系统 +2. `scripts/knowledge-base.js` - 知识库列表页 +3. `scripts/kb-create.js` - 创建知识库Step Form向导 +4. `scripts/kb-detail.js` - 知识库详情页 +5. `index.html` - 添加暗黑模式切换功能 + +### 实现的功能 + +**知识库列表页 (knowledge-base.js)** +- 知识库筛选、搜索、分页、排序 +- 批量操作(批量删除、批量导出) +- 添加知识库弹窗(类型筛选、状态筛选、搜索) +- Skeleton加载状态 +- Empty空状态 + +**创建知识库向导 (kb-create.js)** +- Step 1: 基础信息(名称、描述、类型选择) +- Step 2: 数据选择(文件上传、类目选择、文件选择) +- Step 3: 索引设置(切片方式、检索参数) +- 草稿自动保存/恢复 +- 必填校验 +- 离开确认弹窗 +- API接口预留 + +**知识库详情页 (kb-detail.js)** +- Tab切换(文件管理、标签管理、切片管理、统计信息) +- 文件列表(状态流转、批量操作) +- 切片编辑(内容编辑、历史版本、回滚) +- 命中测试(快捷问题、历史记录) +- 统计信息(切片统计、格式分布、Metadata统计) +- API接口预留 + +**全局功能** +- 暗黑模式切换(支持localStorage持久化) +- Modal弹窗增强(支持size和confirmClass参数) +- Tooltip统一小问号样式 +- 二次确认弹窗 +- Loading/Empty/Error/Skeleton状态 + +### API预留 +所有后端接口已在代码中注释说明,包含: +- GET/POST/DELETE/PUT 各操作的API路径 +- Request和Response字段规范 diff --git a/.workbuddy/memory/2026-05-14.md b/.workbuddy/memory/2026-05-14.md new file mode 100644 index 0000000..c7d95e8 --- /dev/null +++ b/.workbuddy/memory/2026-05-14.md @@ -0,0 +1,59 @@ +# 2026-05-14 工作记录 + +## 修复登录页面CSS样式缺失 + +**问题**:登录页面(login page)使用了大量CSS类名(.login-center-wrapper, .login-form-card, .role-selector, .role-option 等),但 main.css 中完全没有这些类的定义,导致登录页看起来像裸 HTML,所有元素堆在左上角。 + +**修复**:在 styles/main.css 末尾添加了完整的登录页样式(约200行),包括: +- 登录页整体布局(居中、渐变背景) +- 品牌区域样式(TalkingQ logo、标语) +- 登录卡片(白色卡片、圆角、阴影) +- 角色选择器(三列角色卡片,选中状态高亮) +- 表单输入框(带图标、密码切换按钮) +- 记住登录状态复选框 +- 登录按钮(全宽、大号) +- 页脚链接 +- 移动端响应式适配 + +**根因**:之前开发时只写了登录页的 HTML/JS 结构,忘记写对应的 CSS 样式。类似问题已在智能体开发页面(agent-dev.js)出现过一次,这次是登录页面。 + +--- + +## 修复侧边栏公司导航CSS样式缺失 + +**问题**:侧边栏"设备管理"区域的公司列表(.company-nav-item, .company-badge, .company-submenu, .submenu-item)完全没有CSS定义,导致公司名称、设备数量角标、设备管理/OTA升级子菜单都是纯文本堆叠,没有布局、没有图标、没有交互样式。 + +**修复**:在 styles/main.css 登录页样式之后添加了完整的公司导航样式,包括: +- 公司项(company-nav-item):flex布局、hover效果、active选中高亮(绿色背景) +- 设备数量角标(company-badge):圆角标签、active时绿色背景 +- 子菜单(company-submenu):默认隐藏、展开时显示 +- 子菜单项(submenu-item):设备管理/OTA升级链接样式 +- 图标尺寸和颜色适配深色侧边栏 + +**根因**:与登录页问题相同——HTML/JS结构写了,CSS未同步。 + +--- + +## 调整侧边栏公司导航交互和颜色 + +**问题**: +1. 未选中的公司项字体颜色太暗(var(--text-sidebar) 是半透明灰色),用户希望直接显示白色 +2. 子菜单默认隐藏,需要点击才展开,用户希望直接显示 + +**修复**: +- CSS:将 company-nav-item 默认颜色从 var(--text-sidebar) 改为 #fff(白色) +- CSS:将 company-badge 默认颜色和背景调亮 +- CSS:将 company-submenu 默认 display 从 none 改为 flex(始终展开) +- CSS:调整 submenu-item 颜色为 rgba(255,255,255,0.7),hover 变白 +- JS:移除点击公司项时切换子菜单展开/收起的逻辑,只保留选中状态切换 + +--- + +## 调整侧边栏公司导航为手风琴模式 + +**问题**:用户反馈不需要全部展开,希望点到哪个公司哪个展开(手风琴效果)。 + +**修复**: +- CSS:将 company-submenu 默认 display 改回 none(收起状态) +- JS:恢复点击公司项时切换子菜单展开/收起的逻辑 +- JS:添加手风琴效果——展开一个公司时自动收起其他已展开的公司 diff --git a/.workbuddy/memory/2026-05-15.md b/.workbuddy/memory/2026-05-15.md new file mode 100644 index 0000000..b52b20a --- /dev/null +++ b/.workbuddy/memory/2026-05-15.md @@ -0,0 +1,22 @@ +# 2026-05-15 工作记录 + +## 重写三角色权限控制 + 登录页角色改名 + +**改动列表**: + +1. **登录页角色卡片**: + - 工人 → 员工(图标换为 users,data-role 保持 worker) + - 去掉三个角色的权限描述文字(.role-desc) + +2. **登录后跳转**:三个角色统一跳转 dashboard(之前 client 跳 agent-manage,worker 跳 session) + +3. **权限重新分配**(`__updateSidebarForRole`完全重写): + - **管理员**:全部内容可见 + - **员工(worker)**:仪表盘 + 设备管理 + 应用配置 + 会话分析 + 系统管理(隐私保护+系统设置,权限管理隐藏) + - **客户(client)**:仪表盘 + 会话分析(仅会话详情) + 设备管理(仅设备管理,无OTA),顶部公司选择器隐藏 + +4. **客户特殊处理**: + - `initCompanyNav` 增加 role 参数 + - 客户只看自己公司(mock: company_a),无"全部公司"选项 + - 客户的设备管理子菜单不显示 OTA 升级 + - 顶部 company-selector 隐藏 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..daadb1b --- /dev/null +++ b/.workbuddy/memory/MEMORY.md @@ -0,0 +1,35 @@ +# MEMORY.md - TalkingQ平台 长期记忆 + +## 项目信息 +- **项目名**: TalkingQ AI玩具运营与数据洞察平台 +- **类型**: B端运营后台,纯前端(留后端接口) +- **技术栈**: HTML + CSS + Vanilla JS + Chart.js + Lucide Icons +- **品牌色**: #52c41a(绿色,来自商标)、深色侧边栏 #141414 + +## 项目结构 +- 单页应用,index.html 入口 +- styles/main.css 全局样式(约4500行) +- scripts/api.js 所有API接口定义(mock实现) +- 各页面模块单独js文件 + +## 已实现功能 +数据仪表盘、智能体开发(ASR/TTS/RAG/语言)、智能体管理、Prompt管理、 +设备管理、OTA升级、会话详情、Bad Case管理、RBAC权限、PII隐私保护、系统设置 + +## 用户偏好 +- 颜色风格跟商标一致(绿色为主) +- 全前端实现,留接口供未来后端接入 +- 智能体开发需要ASR、TTS、RAG挂载、语言选择功能 + +## 重要修复记录 +### 2026-05-14 - UI 样式缺失修复 +- **问题**: agent-dev.js 中使用的 CSS 类(如 `.agent-config-layout`, `.module-panel`, `.chat-test-panel`, `.skill-grid` 等)在 main.css 中完全缺失 +- **修复**: 在 main.css 末尾添加了约950行缺失的样式定义,包括: + - 应用配置页面布局(agent-config-layout, agent-config-left/right) + - 模块标签和面板样式(module-tabs, module-tab, module-panel) + - 配置行和子区块样式(config-row, config-subsection) + - 场景模板网格(scene-template-grid, scene-item) + - 技能网格(skill-grid, skill-item) + - 对话测试面板(chat-test-panel, chat-messages, chat-bubble) + - 统计卡片(stat-card, stat-value, stat-icon) + - 其他缺失组件样式(tooltip-box, device-status, firmware-version 等) diff --git a/TalkingQ-H5.html b/TalkingQ-H5.html new file mode 100644 index 0000000..545af1d --- /dev/null +++ b/TalkingQ-H5.html @@ -0,0 +1,24115 @@ + + + + + + TalkingQ H5 Preview + + + + + + + + + + +
+ +
+
+ + +
+
+
+
+ +
+
+ 通知中心 + 全部已读 +
+
+
通知加载中...
+
+ +
+
+ +
+ + + + +
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..c376bc6 --- /dev/null +++ b/index.html @@ -0,0 +1,2168 @@ + + + + + + TalkingQ - AI 玩具智能运营平台 + + + + + + + + + +
+ +
+
+ + +
+
+
+
+ +
+
+ 通知中心 + 全部已读 +
+
+
通知加载中...
+
+ +
+
+ +
+ + + + +
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/agent-dev.js b/scripts/agent-dev.js new file mode 100644 index 0000000..9486c88 --- /dev/null +++ b/scripts/agent-dev.js @@ -0,0 +1,1821 @@ +/** + * TalkingQ Platform - Agent Configuration Workbench + * Pure JS component functions following the existing static SPA router. + */ + +const AGENT_CONFIG_STEPS = [ + { id: 'voice', index: 1, title: '语音交互' }, + { id: 'understand', index: 2, title: '理解和生成' }, + { id: 'skills', index: 3, title: '技能' }, + { id: 'advanced', index: 4, title: '高级设置' }, +]; + +const AGENT_CONFIG_DEFAULT_APPS = [ + { id: 'app-001', type: 'multimodal', name: '小Q 故事机器人', status: 'published' }, + { id: 'app-002', type: 'voice', name: '英语学习助手', status: 'editing' }, + { id: 'app-003', type: 'voice', name: '科学百科探索者', status: 'draft' }, + { id: 'app-004', type: 'multimodal', name: '数学小课堂', status: 'published' }, +]; + +const AGENT_SCENE_TEMPLATES = [ + { id: 'ai-audio-glasses', name: 'AI音频眼镜', icon: 'headphones', desc: '随身携带的语音助手' }, + { id: 'ar-glasses', name: 'AR眼镜', icon: 'glasses', desc: '具备屏幕显示能力' }, + { id: 'desktop-robot', name: '桌面机器人', icon: 'bot', desc: '实时语音对话,日常情感陪伴' }, + { id: 'smart-home', name: '智能家居', icon: 'home', desc: '语音控制家中设备' }, + { id: 'toy', name: '儿童玩具', icon: 'star', desc: '陪伴孩子聊天、放音乐、讲故事' }, + { id: 'story-player', name: '儿童故事机', icon: 'book-open', desc: '给孩子讲故事、放儿歌' }, + { id: 'smart-speaker', name: '智能音箱', icon: 'volume-2', desc: '语音控制播放音乐' }, + { id: 'mobile-app', name: '手机应用', icon: 'smartphone', desc: '实时语音互动应用' }, +]; + +const AGENT_SCENE_VERSIONS = [ + { + id: 'full', + name: '全能版', + desc: '支持意图识别、工具调用、联网搜索、多场景 Agent,适合完整智能体体验。', + }, + { + id: 'lite', + name: '轻量版', + desc: '更快速、更低价的语音闲聊,不启用复杂工具调用和 Agent 编排。', + }, +]; + +const AGENT_SCENE_LANGUAGES = [ + { id: 'zh', name: '中文', desc: '适合中文普通话和中文儿童内容场景' }, + { id: 'en', name: '英文', desc: '适合英语对话、英语学习和双语启蒙场景' }, +]; + +const AGENT_CONFIG_MOCK = { + voice: { + languageOptions: [ + { id: 'zh', label: '中文' }, + { id: 'en', label: '英文' }, + ], + asrModels: [ + { + id: 'fun-asr', + icon: 'audio-lines', + label: 'Fun-ASR实时语音识别', + desc: '中英文自由切换,多地区方言覆盖,适应多样复杂环境。', + }, + { + id: 'qwen3-asr', + icon: 'audio-lines', + label: '通义千问3-ASR-Flash-Realtime', + desc: '高精度语音识别,中英日韩德法俄意西葡阿和多种方言。', + }, + { + id: 'paraformer', + icon: 'audio-lines', + label: 'Paraformer实时语音识别', + desc: '新一代非自回归端到端模型,支持中英日韩德法俄等语言。', + }, + { + id: 'multimodal-lite-asr', + icon: 'audio-lines', + label: '多模态交互轻量版语音识别', + desc: '超低价格,预算友好,支持中英等语言。', + }, + ], + ttsGroups: [ + { + id: 'cosyvoice', + title: 'CosyVoice-实时语音合成', + models: [ + { id: 'cosyvoice-v3-flash', label: 'CosyVoice-v3-Flash大模型', desc: '独家音色,性价比高,支持情感控制。' }, + { id: 'cosyvoice-v3-plus', label: 'CosyVoice-v3-Plus大模型', desc: '情感表达最佳,支持声音复刻。' }, + { id: 'cosyvoice-v35-plus', label: 'CosyVoice-v3.5-Plus大模型', desc: '声音设计/复刻最佳,多种方言和外语。' }, + { id: 'cosyvoice-v35-flash', label: 'CosyVoice-v3.5-Flash大模型', desc: '声音设计/复刻性价比高,多种方言和外语。' }, + { id: 'cosyvoice-v2', label: 'CosyVoice-v2大模型', desc: '音色丰富,70+音色可选,一句话声音复刻。' }, + ], + }, + { + id: 'qwen-tts', + title: '通义千问3-实时语音合成', + models: [ + { id: 'qwen3-tts-flash', label: '通义千问3-TTS-Flash-Realtime', desc: '多语言,拥有17种高表现力的拟人音色。' }, + { id: 'qwen3-tts-instruct', label: '通义千问3-TTS-Instruct-Flash-Realtime', desc: '支持指令控制,调整音调、语速、情感、特点。' }, + { id: 'qwen3-tts-design', label: '通义千问3-TTS-声音设计', desc: '通过文本描述生成定制化音色,支持多维度定义。' }, + { id: 'qwen3-tts-clone', label: '通义千问3-TTS-声音复刻', desc: '10-20秒音频即可生成效果最佳的定制音色。' }, + ], + }, + { + id: 'others', + title: '其他语音合成模型', + models: [ + { id: 'sambert-tts', label: 'Sambert-实时语音合成', desc: '音色选择多,多语言,合成速度快。' }, + { id: 'lite-tts', label: '多模态交互轻量版语音合成', desc: '预算友好,支持多语种和方言。' }, + ], + }, + ], + defaultHotwords: ['TalkingQ', '小Q', '白雪公主', '睡前故事', '恐龙百科'], + correctionHint: '通过接口上传词表,对语音识别结果进行实时干预。', + }, + understand: { + intentModel: { + icon: 'route', + label: '意图识别', + desc: '关闭将不再触发调用工具/Agent 等基于语义理解的交互功能。', + }, + textModels: [ + { id: 'recommended-fast', group: '推荐模型', label: '多模态交互专有模型-高速版', desc: '对话速度更快,适合通用场景、闲聊陪伴场景。' }, + { id: 'recommended-tool', group: '推荐模型', label: '多模态交互专有模型', desc: '指令遵循能力更强,适合工作学习场景。' }, + { id: 'recommended-style', group: '推荐模型', label: '多模态交互专有模型-风格化版', desc: '风格化能力更强,适合角色扮演等场景。' }, + { id: 'qwen', group: '更多模型', label: '多千问', desc: '通用能力均衡,适合大多数任务。' }, + { id: 'deepseek', group: '更多模型', label: 'DeepSeek', desc: '推理和代码表现更强。' }, + { id: 'chatgpt', group: '更多模型', label: 'ChatGPT', desc: '多场景通用对话能力。' }, + { id: 'doubao', group: '更多模型', label: '豆包', desc: '适合中文对话和内容生成。' }, + ], + prompt: `# 角色 +你是小Q,一个专为 3-10 岁儿童设计的 AI 玩具智能体。 + +## 沟通风格 +- 语言简单、生动、温暖。 +- 回复通常控制在 150 字以内。 +- 优先使用比喻、拟声词和故事化表达。 +- 不能输出任何不适合儿童的危险内容。 + +## 行为准则 +1. 当孩子要求讲故事时,先确认主题,再生成短故事。 +2. 当孩子提问百科知识时,先给出结论,再给出一个生活例子。 +3. 当识别到情绪陪伴需求时,优先安抚,再引导表达。 + +\`\`\`policy +安全等级:儿童模式 +联网搜索:必要时开启 +知识库优先级:高 +\`\`\``, + variables: [ + { name: 'child_age', desc: '儿童年龄' }, + { name: 'device_mode', desc: '设备当前模式' }, + { name: 'ambient_noise', desc: '环境噪音等级' }, + { name: 'weather', desc: '当前位置天气' }, + ], + knowledgeBases: [ + { name: '儿童科学百科', desc: '动物、太空、自然科学问答素材', weight: '0.72' }, + { name: '故事素材库 V2', desc: '睡前故事、角色设定、儿童互动剧情', weight: '0.68' }, + ], + }, + skills: { + commands: [ + { icon: 'phone-call', name: '打电话', count: 6, desc: '拨打联系人电话、语音拨号等电话相关控制' }, + { icon: 'alarm-clock', name: '定闹钟', count: 1, desc: '创建、关闭、修改闹钟' }, + { icon: 'volume-2', name: '音量设置', count: 7, desc: '调节媒体音量、铃声音量、静音等' }, + { icon: 'bluetooth', name: '蓝牙', count: 4, desc: '打开/关闭蓝牙、连接蓝牙设备' }, + { icon: 'toggle-right', name: '应用开关', count: 10, desc: '打开/关闭指定 App 或系统功能' }, + { icon: 'cpu', name: '设备控制', count: 5, desc: '系统级设备控制能力(当前已选中)' }, + { icon: 'music', name: '音乐播放', count: 5, desc: '播放音乐、暂停、下一首等' }, + { icon: 'circle-play', name: '多媒体控制', count: 6, desc: '控制媒体播放状态与音频输出' }, + { icon: 'camera', name: '拍照录像', count: 6, desc: '打开相机、拍照、录像' }, + { icon: 'mic', name: '录音', count: 2, desc: '开始/停止录音' }, + { icon: 'book-open', name: '朗读文字', count: 1, desc: '语音朗读输入文本' }, + { icon: 'message-circle', name: '发短信', count: 3, desc: '发送短信消息' }, + { icon: 'sun', name: '亮度设置', count: 5, desc: '调节屏幕亮度' }, + { icon: 'sun-medium', name: '色温设置', count: 3, desc: '调节屏幕色温/护眼模式' }, + { icon: 'video', name: '视频播放', count: 1, desc: '打开并播放视频' }, + { icon: 'map-pinned', name: '打开地图', count: 1, desc: '打开地图导航类应用' }, + { icon: 'monitor', name: '屏幕控制', count: 4, desc: '锁屏、亮屏、熄屏、旋转等屏幕操作' }, + ], + plugins: [ + { icon: 'cloud-sun', name: '天气查询', desc: '查询实时天气、空气质量和未来预报', status: '系统执行' }, + { icon: 'calendar', name: '万年历', desc: '查询日期、农历、节假日和宜忌信息', status: '系统执行' }, + { icon: 'newspaper', name: '新闻资讯', desc: '获取最新新闻资讯和摘要播报', status: '系统执行' }, + { icon: 'navigation', name: '路线规划', desc: '规划出行路线、距离和预计耗时', status: '系统执行' }, + { icon: 'map', name: '本地生活', desc: '查询周边商户、生活服务和地点信息', status: '系统执行' }, + ], + bailianApps: [ + { name: '故事生成器', desc: '基于主题生成短故事' }, + { name: '儿童内容安全', desc: '对回答内容进行安全审查' }, + ], + thirdPartyApps: [ + { name: '音频内容库', desc: '接入第三方儿歌和故事资源' }, + ], + }, +}; + +registerPage('agent-config', function(container) { + if (typeof window.__ensureAgentHotwordGroups === 'function') { + window.__ensureAgentHotwordGroups(); + } else if (!window.__agentHotwordGroups) { + window.__agentHotwordGroups = [ + { + id: 'hw-001', + name: '品牌基础热词', + mode: 'manual', + words: [ + { text: 'TalkingQ', lang: '英文' }, + { text: '小Q', lang: '中文' }, + { text: '故事机', lang: '中文' }, + ], + updatedAt: '2026-05-20 11:57:38', + }, + { + id: 'hw-002', + name: '儿童内容热词', + mode: 'manual', + words: [ + { text: '白雪公主', lang: '中文' }, + { text: '睡前故事', lang: '中文' }, + ], + updatedAt: '2026-05-20 12:08:16', + }, + ]; + } + const apps = window.__agentManagedApps || AGENT_CONFIG_DEFAULT_APPS; + const selectedApp = apps.find(app => app.id === window.__selectedAppId) || apps[0] || AGENT_CONFIG_DEFAULT_APPS[0]; + const firstHotwordGroup = window.__agentHotwordGroups?.[0]?.id || ''; + const state = { + app: selectedApp, + activeStep: 'voice', + preview: 'empty', + selectedSceneId: window.__selectedSceneTemplateId || 'toy', + selectedSceneVersion: window.__selectedSceneVersion || 'full', + selectedSceneLanguage: window.__selectedSceneLanguage || 'zh', + voiceEnabled: true, + asrEnabled: true, + ttsEnabled: true, + selectedAsrModelId: AGENT_CONFIG_MOCK.voice.asrModels[0]?.id || '', + selectedTtsGroupId: AGENT_CONFIG_MOCK.voice.ttsGroups[0]?.id || '', + selectedTtsModelId: AGENT_CONFIG_MOCK.voice.ttsGroups[0]?.models[0]?.id || '', + selectedHotwordGroupId: firstHotwordGroup, + realtimeCorrectionEnabled: true, + ttsFilterEnabled: false, + ttsFilterPairs: [ + { enabled: true, left: '/*', right: '*/' }, + { enabled: true, left: '[', right: ']' }, + { enabled: false, left: '{', right: '}' }, + ], + ttsFilterSingles: [], + interruptPrimaryEnabled: true, + interruptSecondaryEnabled: true, + interruptMode: 'full', + semanticStopEnabled: true, + edgeAlgorithmExpanded: true, + selectedTextModelMode: 'recommended', + selectedTextModelId: AGENT_CONFIG_MOCK.understand.textModels[0]?.id || '', + intentEnabled: true, + dialogueContinuationEnabled: true, + knowledgeSearchEnabled: true, + searchMode: 'fast', + longTermMemoryEnabled: false, + knowledgeBases: AGENT_CONFIG_MOCK.understand.knowledgeBases.map(item => ({ ...item })), + customCommands: [], + customPlugins: [], + }; + + function render() { + const scrollArea = container.querySelector('.bailian-config-scroll'); + const previousScrollTop = scrollArea?.scrollTop || 0; + if (state.selectedHotwordGroupId && !(window.__agentHotwordGroups || []).some(group => group.id === state.selectedHotwordGroupId)) { + state.selectedHotwordGroupId = window.__agentHotwordGroups?.[0]?.id || ''; + } + container.innerHTML = renderAgentWorkbench(state); + renderAgentIcons(container); + bindWorkbenchEvents(container, state, render); + bindPromptCounter(container); + requestAnimationFrame(() => { + const nextScrollArea = container.querySelector('.bailian-config-scroll'); + if (nextScrollArea) { + nextScrollArea.scrollTop = previousScrollTop; + } + }); + } + + const onHotwordGroupsChanged = () => { + if (!container.isConnected || window.__currentPage !== 'agent-config') { + window.removeEventListener('hotwordGroupsChanged', onHotwordGroupsChanged); + return; + } + render(); + }; + window.addEventListener('hotwordGroupsChanged', onHotwordGroupsChanged); + + render(); +}); + +function renderAgentWorkbench(state) { + return ` +
+ ${renderAgentHeader(state.app)} +
+ + ${renderPreviewPane(state.preview)} +
+
+ `; +} + +function renderAgentHeader(app) { + const status = getAgentStatus(app.status); + return ` +
+
+ +
+
+

${escapeHtml(app.name)}

+ + ${status.label} +
+
ID: ${escapeHtml(app.id)} · AI 智能体配置平台
+
+
+
+ + + + +
+
+ `; +} + +function renderStepTabs(activeStep) { + return ` + + `; +} + +function renderAllStepSections(state) { + return AGENT_CONFIG_STEPS.map(step => ` +
+ ${renderStepContent(step.id, state)} +
+ `).join(''); +} + +function renderStepContent(stepId, state) { + const renderers = { + voice: renderVoiceStep, + understand: renderUnderstandStep, + skills: renderSkillsStep, + advanced: renderAdvancedStep, + }; + return (renderers[stepId] || renderVoiceStep)(state); +} + +function renderVoiceStep(state) { + const data = AGENT_CONFIG_MOCK.voice; + const scene = getSelectedScene(state); + const version = getSelectedSceneVersion(state); + const language = getSelectedSceneLanguage(state); + const hotwordGroups = window.__agentHotwordGroups || []; + return ` +
+
+

语音交互

+ ${renderSceneCard(scene, version, language)} +
+ +
+
+
开启语音交互
+
打开后运行将使用语音进行交互,关闭后仅保留文字交互
+
+ +
+
+ ${state.voiceEnabled ? ` +
+
语音 AI
+
+ ${renderFeatureSelect({ + title: '语音识别', + toggleKey: 'asr-enabled', + checked: state.asrEnabled, + activeId: state.selectedAsrModelId, + options: data.asrModels, + hint: '前面的开关决定是否启用 ASR,后面的下拉用于切换识别模型', + })} + ${renderFeatureSelect({ + title: '语音合成', + toggleKey: 'tts-enabled', + checked: state.ttsEnabled, + activeId: state.selectedTtsModelId, + options: data.ttsGroups.flatMap(group => group.models.map(model => ({ ...model, groupTitle: group.title }))), + groupedOptions: data.ttsGroups, + hint: '前面的开关决定是否启用 TTS,后面的下拉用于切换播报模型', + })} +
+
+
+
热词
+
+
+
热词组绑定
+
热词组来自热词库管理,新增后会自动出现在这里
+
+ +
+ +
+
+
即时纠错
+
+
+
开启即时纠错
+
${escapeHtml(data.correctionHint)}
+
+ +
+
+ 可上传词表对识别结果进行实时干预 + +
+
+
+
语音合成播报过滤
+
+
+
开启播报过滤
+
特定符号对中的内容将不会进行 TTS 播报
+
+ +
+ ${state.ttsFilterEnabled ? ` +
+
选中符号对内的文字将不会通过 TTS 播报,如 [动作/表情编码]、[设备参数] 及 $$公式$$ 等。
+ +
+ ` : ''} +
+ ${state.ttsFilterEnabled ? renderTtsFilterSummary(state) : ''} +
+
对话打断
+
此配置仅用于效果体验
+
+ + +
+ + + 长按对话(半双工,仅移动端支持) + 运行后可在移动端长按触发 + +
+
+
+
+
语义判停与拒识
+
+
+
开启语义判停与拒识
+
开启后可能导致对话延迟增加
+
+ +
+
基于用户语义,判断是否结束语音输入,以及是否需要模型回复。全双工对话场景下,本功能效果更显著。
+
+
+
端侧算法
+ + ${state.edgeAlgorithmExpanded ? ` +
+ ${[ + ['语音唤醒', '默认唤醒词“小云小云”'], + ['端侧 VAD', '语音活性检测,判断是否存在语音信号'], + ['AEC', '回声消除,减少自身播报的干扰'], + ['定向拾音', '增强特定方向拾音,抑制其他方向'], + ].map(([title, desc]) => ` +
+ ${escapeHtml(title)} + ${escapeHtml(desc)} +
+ `).join('')} +
+ ` : ''} +
+ + ` : ` +
+ 当前为文字交互模式 + 关闭语音交互后,不展示语音 AI、热词、纠错、播报过滤、打断和端侧算法配置。 +
+ `} +
+
+ `; +} + +function renderUnderstandStep(state) { + const data = AGENT_CONFIG_MOCK.understand; + const selectedMode = state.selectedTextModelMode || 'recommended'; + const selectedModelId = state.selectedTextModelId || data.textModels[0]?.id || ''; + const selectedModel = data.textModels.find(item => item.id === selectedModelId) + || data.textModels.find(item => item.group === '推荐模型') + || data.textModels[0]; + return ` +
+ ${renderSection('意图识别', ` +
+
+
开启意图识别
+
${escapeHtml(data.intentModel.desc)}
+
+ +
+
关闭将不再触发调用工具/Agent 等基于语义理解的交互功能,适用于低成本、轻量级语音交互场景。
+ `)} + ${renderSection('文本模型', ` +
+
+
模型选择
+
建议使用推荐模型,为用户提供流畅的对话体验
+
+ 推荐 / 更多 +
+
+ + +
+ +
${escapeHtml(selectedModel.desc)}
+ `)} + ${renderPromptEditor(data.prompt)} + ${renderSection('对话变量', ` +
+ 支持在对话输入中添加变量,为模型提供用户语音之外的更多信息,如天气、周围环境等。 + +
+
+ ${data.variables.map(item => ` +
+
${escapeHtml(item.name)}
+
${escapeHtml(item.desc)}
+
+ `).join('')} + +
+ `)} + ${renderSection('上下文轮数', ` +
+ 携带最近 + + 轮对话作为上下文 +
+ `)} + ${renderKnowledgeBaseSection(state.knowledgeBases || data.knowledgeBases)} + ${renderSection('对话承接语', ` +
+
+
开启对话承接语
+
大模型基于用户输入自动生成承接语,减少回复等待时间
+
+ +
+ `)} + ${renderSection('互联网搜索', ` +
+
+
开启互联网搜索
+
关闭后不展示搜索模式,仅保留本地知识和模型能力
+
+ +
+ ${state.knowledgeSearchEnabled !== false ? ` +
+ + +
+ ` : ''} + `)} + ${renderSection('长期记忆', ` +
+
+
开启长期记忆
+
基于对话历史形成专属记忆库,并在后续对话中体现
+
+ +
+
开启长期记忆后,结合记忆内容进行回复的对话会增加额外耗时。
+ `)} +
+ `; +} + +function renderSkillsStep(state) { + const data = AGENT_CONFIG_MOCK.skills; + const commands = [...data.commands, ...(state.customCommands || [])]; + const plugins = [...data.plugins, ...(state.customPlugins || [])]; + return ` +
+ ${renderSection('指令', ` +
+ 当前已选择的指令可以编辑,右侧加号可添加新的指令类型 + +
+
+ ${commands.map(item => ` +
+
+
+
${escapeHtml(item.name)} ${item.count || 1} 指令
+
${escapeHtml(item.desc)}
+
+ + +
+ `).join('')} +
+ `)} + ${renderSection('插件', ` +
+ 插件能力默认由系统执行,也可以独立配置启用状态 + +
+
+ ${plugins.map(item => ` +
+
+
+
${escapeHtml(item.name)}
+
${escapeHtml(item.desc)}
+
+ ${escapeHtml(item.status || '系统执行')} + + +
+ `).join('')} +
+ `)} + ${renderMiniAppSection('百炼应用', data.bailianApps, 'boxes')} + ${renderMiniAppSection('三方应用', data.thirdPartyApps, 'plug')} + ${renderMcpSection()} + ${renderSection('Planning / Function Calling', ` + ${renderSwitchRow('开启 Planning', '让智能体先规划步骤,再调用函数或插件完成复杂任务', true)} + ${renderSwitchRow('开启 Function Calling', '允许模型按照结构化参数调用已授权函数', true)} + `)} +
+ `; +} + +function renderAdvancedStep() { + return ` +
+ ${renderSection('高级设置', ` + ${renderSwitchRow('儿童安全模式', '强制启用儿童内容安全策略、危险行为拒答和敏感信息保护', true)} + ${renderSwitchRow('链路追踪', '记录 ASR、LLM、RAG、TTS 的耗时和调用链路', true)} + ${renderSwitchRow('自动降级', '主模型异常时自动切换到备用模型,保证设备可用性', true)} + `)} + ${renderSection('调用预算', ` +
+ + +
+ `)} + ${renderSection('发布策略', ` +
+ ${renderRadio('release', 'gray', '灰度发布 10%', true)} + ${renderRadio('release', 'full', '全量发布')} + ${renderRadio('release', 'schedule', '定时发布')} +
+ `)} +
+ `; +} + +function renderPreviewPane(previewState) { + const title = previewState === 'running' ? '预览环境已准备好' : '请填好配置后点击运行'; + const desc = previewState === 'running' + ? '当前智能体已加载最新 mock 配置,可继续调整参数后再次运行。' + : '当前应用测试会产生实际调用,按对话次数计费'; + return ` +
+
+ +

${title}

+

${desc}

+
+ + 建议佩戴耳机,并在安静环境下测试,对话体验更佳。 +
+
+
+ `; +} + +function renderSceneCard(scene, version, language) { + return ` + + `; +} + +function renderSection(title, content) { + return ` +
+

${escapeHtml(title)}

+ ${content} +
+ `; +} + +function renderModelSelect(model) { + return ` + + `; +} + +function renderFeatureSelect(config) { + const { + title, + toggleKey, + checked, + activeId, + options = [], + groupedOptions = null, + hint = '', + } = config; + const fieldAction = toggleKey === 'asr-enabled' ? 'asr-model' + : toggleKey === 'tts-enabled' ? 'tts-model' + : 'model'; + const selectedOption = options.find(item => item.id === activeId) || options[0] || null; + return ` +
+
+
+
${escapeHtml(title)}
+
${escapeHtml(hint)}
+
+ +
+ + ${selectedOption ? `
${escapeHtml(selectedOption.desc)}
` : ''} +
+ `; +} + +function renderSwitchRow(label, desc, checked) { + return ` +
+
+
${escapeHtml(label)}
+
${escapeHtml(desc)}
+
+ +
+ `; +} + +function renderRadio(name, value, label, checked = false) { + return ` + + `; +} + +function renderTtsFilterSummary(state) { + if (!state.ttsFilterEnabled) return ''; + const commonPairs = (state.ttsFilterPairs || []) + .filter(item => item.enabled !== false) + .map(item => `${item.left} ${item.right}`.trim()); + const singles = state.ttsFilterSingles || []; + return ` +
+
+
常用过滤符号对
+
+ ${commonPairs.length ? commonPairs.map(item => `${escapeHtml(item)}`).join('') : '暂无'} +
+
+
+
自定义过滤符号对与单字符
+
+ ${singles.length ? singles.map(item => `${escapeHtml(item)}`).join('') : '暂无'} +
+ +
+
+ `; +} + +function renderPromptEditor(prompt) { + return ` +
+
+

Prompt 提示词

+ +
+ + +
+ `; +} + +function renderKnowledgeBaseSection(items) { + return renderSection(`知识库 ${items.length}/10`, ` +
+ 已挂载知识库会参与 RAG 检索和答案补充 +
+ + +
+
+
+ ${items.map(item => ` +
+
+
+
${escapeHtml(item.name)}
+
${escapeHtml(item.desc)}
+
权重 ${escapeHtml(item.weight)}
+
+ +
+ `).join('')} +
+ `); +} + +function renderWebSearchSection(modes) { + return renderSection('联网搜索', ` +
+ ${modes.map(mode => ` + + `).join('')} +
+ `); +} + +function renderMiniAppSection(title, items, iconName) { + return renderSection(title, ` +
+ ${items.map(item => ` +
+ + ${escapeHtml(item.name)} + ${escapeHtml(item.desc)} +
+ `).join('')} + +
+ `); +} + +function renderMcpSection() { + return renderSection('MCP服务 0', ` +
+
+ MCP服务 0 +

智能体可以通过标准化协议(MCP)连接企业内部服务API并发起调用

+
+ +
+ `); +} + +function bindWorkbenchEvents(container, state, rerender) { + container.querySelectorAll('[data-step]').forEach(tab => { + tab.addEventListener('click', () => { + state.activeStep = tab.dataset.step; + updateActiveStepTab(container, state.activeStep); + scrollToAgentStep(container, state.activeStep); + }); + }); + + bindStepScrollSpy(container, state); + + container.querySelectorAll('[data-toggle]').forEach(input => { + input.addEventListener('change', () => { + const key = input.dataset.toggle; + const checked = input.checked; + const map = { + 'voice-enabled': 'voiceEnabled', + 'asr-enabled': 'asrEnabled', + 'tts-enabled': 'ttsEnabled', + 'realtime-correction': 'realtimeCorrectionEnabled', + 'tts-filter': 'ttsFilterEnabled', + 'interrupt-primary': 'interruptPrimaryEnabled', + 'interrupt-secondary': 'interruptSecondaryEnabled', + 'semantic-stop': 'semanticStopEnabled', + 'intent-enabled': 'intentEnabled', + 'continuation': 'dialogueContinuationEnabled', + 'knowledge-search': 'knowledgeSearchEnabled', + 'long-term-memory': 'longTermMemoryEnabled', + }; + const stateKey = map[key]; + if (stateKey) { + state[stateKey] = checked; + rerender(); + } + }); + }); + + container.querySelectorAll('input[name="textModelMode"]').forEach(radio => { + radio.addEventListener('change', () => { + if (!radio.checked) return; + state.selectedTextModelMode = radio.value; + const group = radio.value === 'recommended' ? '推荐模型' : '更多模型'; + const candidates = AGENT_CONFIG_MOCK.understand.textModels.filter(item => item.group === group); + state.selectedTextModelId = candidates[0]?.id || state.selectedTextModelId; + rerender(); + }); + }); + + container.querySelectorAll('input[name="webSearchMode"]').forEach(radio => { + radio.addEventListener('change', () => { + if (!radio.checked) return; + state.searchMode = radio.value; + rerender(); + }); + }); + + container.querySelectorAll('[data-action="text-model"]').forEach(select => { + select.addEventListener('change', () => { + state.selectedTextModelId = select.value; + const current = AGENT_CONFIG_MOCK.understand.textModels.find(item => item.id === select.value); + if (current) state.selectedTextModelMode = current.group === '更多模型' ? 'more' : 'recommended'; + rerender(); + }); + }); + + container.querySelectorAll('[data-action="asr-model"]').forEach(select => { + select.addEventListener('change', () => { + state.selectedAsrModelId = select.value; + rerender(); + }); + }); + + container.querySelectorAll('[data-action="tts-model"]').forEach(select => { + select.addEventListener('change', () => { + state.selectedTtsModelId = select.value; + const matchedGroup = AGENT_CONFIG_MOCK.voice.ttsGroups.find(group => group.models.some(model => model.id === select.value)); + state.selectedTtsGroupId = matchedGroup?.id || state.selectedTtsGroupId; + rerender(); + }); + }); + + container.querySelectorAll('[data-action="hotword-group"]').forEach(select => { + select.addEventListener('change', () => { + state.selectedHotwordGroupId = select.value; + rerender(); + }); + }); + + container.querySelectorAll('[data-action="scene-language"]').forEach(select => { + select.addEventListener('change', () => { + state.selectedSceneLanguage = select.value; + window.__selectedSceneLanguage = select.value; + rerender(); + }); + }); + + container.querySelectorAll('[data-action]').forEach(el => { + el.addEventListener('click', event => { + const action = el.dataset.action; + if (action === 'back') { + navigateTo('agent-manage-multimodal'); + return; + } + if (action === 'rename') { + openRenameModal(state, rerender); + return; + } + if (action === 'publish') { + state.app.status = 'published'; + Toast.success('应用已提交发布,预计 2-5 分钟生效'); + rerender(); + return; + } + if (action === 'run') { + state.preview = 'running'; + updatePreviewOnly(container, state.preview); + Toast.success('运行预览已启动'); + return; + } + if (action === 'add-kb') { + navigateTo('knowledge-base'); + return; + } + if (action === 'knowledge-weight') { + openKnowledgeWeightModal(state, rerender); + return; + } + if (action === 'scene') { + openSceneTemplateModal(state, rerender); + return; + } + if (action === 'hotwords') { + navigateTo('hotword-library'); + return; + } + if (action === 'tts-filter-config') { + openTtsFilterModal(state, rerender); + return; + } + if (action === 'command-config') { + openCommandConfigModal(state, rerender); + return; + } + if (action === 'plugin-config') { + openPluginConfigModal(state, rerender); + return; + } + if (action === 'edge-collapse') { + state.edgeAlgorithmExpanded = !state.edgeAlgorithmExpanded; + rerender(); + return; + } + const actionText = { + history: '打开历史记录', + api: '打开 API 接入文档', + license: '打开 License 购买页', + model: '切换模型', + variable: '打开变量配置', + setting: '打开设置', + remove: '已移除当前项', + mcp: '添加 MCP 服务', + 'add-app': '添加应用', + 'correction-upload': '上传词表', + }; + Toast.info(actionText[action] || '操作已触发'); + event.stopPropagation(); + }); + }); +} + +function updateActiveStepTab(container, stepId) { + container.querySelectorAll('.bailian-step-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.step === stepId); + }); +} + +function scrollToAgentStep(container, stepId) { + const scrollArea = container.querySelector('.bailian-config-scroll'); + const target = container.querySelector(`#agent-step-${stepId}`); + if (!scrollArea || !target) return; + const offset = scrollArea.scrollTop + + target.getBoundingClientRect().top + - scrollArea.getBoundingClientRect().top + - 8; + scrollArea.scrollTo({ top: Math.max(offset, 0), behavior: 'smooth' }); +} + +function bindStepScrollSpy(container, state) { + const scrollArea = container.querySelector('.bailian-config-scroll'); + if (!scrollArea) return; + let ticking = false; + scrollArea.addEventListener('scroll', () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + const marker = scrollArea.getBoundingClientRect().top + 96; + let currentStep = AGENT_CONFIG_STEPS[0].id; + AGENT_CONFIG_STEPS.forEach(step => { + const section = container.querySelector(`#agent-step-${step.id}`); + if (section && section.getBoundingClientRect().top <= marker) { + currentStep = step.id; + } + }); + if (state.activeStep !== currentStep) { + state.activeStep = currentStep; + updateActiveStepTab(container, currentStep); + } + ticking = false; + }); + }); +} + +function updatePreviewOnly(container, previewState) { + const preview = container.querySelector('.bailian-preview-pane'); + if (!preview) return; + const temp = document.createElement('div'); + temp.innerHTML = renderPreviewPane(previewState); + preview.replaceWith(temp.firstElementChild); + renderAgentIcons(container.querySelector('.bailian-preview-pane')); +} + +function bindPromptCounter(container) { + const editor = container.querySelector('#agentPromptEditor'); + const counter = container.querySelector('#promptCounter'); + if (!editor || !counter) return; + const update = () => { + counter.textContent = `${editor.value.length}/8000`; + }; + editor.addEventListener('input', update); + update(); +} + +function openSceneTemplateModal(state, rerender) { + let pendingSceneId = state.selectedSceneId; + let pendingVersionId = state.selectedSceneVersion; + let pendingLanguageId = state.selectedSceneLanguage; + + if (typeof createModal !== 'function') { + Toast.info('场景模板选择器暂不可用'); + return; + } + + const modal = createModal({ + title: '切换场景模板', + size: 'xl', + confirmText: '应用模板', + cancelText: '取消', + content: ` +
+
+
选择场景
+
+ ${AGENT_SCENE_TEMPLATES.map(scene => ` + + `).join('')} +
+
+
+
选择版本
+
+ ${AGENT_SCENE_VERSIONS.map(version => ` + + `).join('')} +
+
+
+
对话语言 *
+
+ ${AGENT_SCENE_LANGUAGES.map(language => ` + + `).join('')} +
+
+
+ `, + onConfirm: () => { + if (!pendingLanguageId || !AGENT_SCENE_LANGUAGES.some(language => language.id === pendingLanguageId)) { + Toast.warning('请选择对话语言'); + return false; + } + state.selectedSceneId = pendingSceneId; + state.selectedSceneVersion = pendingVersionId; + state.selectedSceneLanguage = pendingLanguageId; + window.__selectedSceneTemplateId = pendingSceneId; + window.__selectedSceneVersion = pendingVersionId; + window.__selectedSceneLanguage = pendingLanguageId; + const scene = getSelectedScene(state); + const version = getSelectedSceneVersion(state); + const language = getSelectedSceneLanguage(state); + Toast.success(`已切换至「${scene.name} · ${version.name} · ${language.name}」`); + rerender(); + return true; + }, + }); + + const overlay = modal.overlay; + const syncSceneOptions = () => { + overlay.querySelectorAll('.bailian-scene-option').forEach(option => { + option.classList.toggle('selected', option.dataset.sceneId === pendingSceneId); + option.querySelector('input').checked = option.dataset.sceneId === pendingSceneId; + }); + }; + const syncVersionOptions = () => { + overlay.querySelectorAll('.bailian-scene-version-option').forEach(option => { + option.classList.toggle('selected', option.dataset.versionId === pendingVersionId); + option.querySelector('input').checked = option.dataset.versionId === pendingVersionId; + }); + }; + const syncLanguageOptions = () => { + overlay.querySelectorAll('.bailian-scene-language-option').forEach(option => { + option.classList.toggle('selected', option.dataset.languageId === pendingLanguageId); + option.querySelector('input').checked = option.dataset.languageId === pendingLanguageId; + }); + }; + + overlay.querySelectorAll('.bailian-scene-option').forEach(option => { + option.addEventListener('click', () => { + pendingSceneId = option.dataset.sceneId; + syncSceneOptions(); + }); + }); + overlay.querySelectorAll('.bailian-scene-version-option').forEach(option => { + option.addEventListener('click', () => { + pendingVersionId = option.dataset.versionId; + syncVersionOptions(); + }); + }); + overlay.querySelectorAll('.bailian-scene-language-option').forEach(option => { + option.addEventListener('click', () => { + pendingLanguageId = option.dataset.languageId; + syncLanguageOptions(); + }); + }); + renderAgentIcons(overlay); +} + +function openKnowledgeWeightModal(state, rerender) { + const draft = (state.knowledgeBases || AGENT_CONFIG_MOCK.understand.knowledgeBases).map(item => ({ ...item })); + const modal = createModal({ + title: '知识库权重配置', + size: 'lg', + confirmText: '确定', + cancelText: '取消', + content: ` +
+
调整知识库权重可人工干预召回片段排序。知识库召回片段得分 = 知识库权重 × 切片相似度。
+ ${draft.map((item, index) => ` +
+
+
${escapeHtml(item.name)}
+
${escapeHtml(item.desc)}
+
+ +
+ `).join('')} +
+ `, + onConfirm: () => { + const inputs = Array.from(modal.overlay.querySelectorAll('.bailian-weight-input')); + const next = inputs.map((input, index) => { + const weight = Number.parseFloat(input.value); + if (Number.isNaN(weight) || weight < 0.5 || weight > 2) { + Toast.warning('知识库权重需在 0.5 到 2 之间'); + input.focus(); + return null; + } + return { ...draft[index], weight: weight.toFixed(2) }; + }); + if (next.some(item => !item)) return false; + state.knowledgeBases = next; + Toast.success('知识库权重已更新'); + rerender(); + return true; + }, + }); + renderAgentIcons(modal.overlay); +} + +function openTtsFilterModal(state, rerender) { + const commonPairs = [ + { id: 'slash', left: '/*', right: '*/', label: '/* */' }, + { id: 'square', left: '[', right: ']', label: '[ ]' }, + { id: 'curly', left: '{', right: '}', label: '{ }' }, + ]; + const draftPairs = (state.ttsFilterPairs || []).map(item => ({ ...item })); + const draftSingles = [...(state.ttsFilterSingles || [])]; + + const modal = createModal({ + title: '语音合成播报过滤设置', + size: 'xl', + confirmText: '确定', + cancelText: '取消', + content: ` +
+
选中符号对内的文字将不会通过 TTS 播报;单字符过滤在所有场景下都不会播报,请谨慎评估业务文本特点。
+
+
常用过滤符号对
+
+ ${commonPairs.map(item => ` + + `).join('')} +
+
+
+
+
自定义过滤符号对
+ +
+
+
+
+
+
自定义过滤单字符
+ +
+
+
+
+ `, + onConfirm: () => { + const pairRows = Array.from(modal.overlay.querySelectorAll('[data-filter-pair-row]')); + const singleRows = Array.from(modal.overlay.querySelectorAll('[data-filter-single-row]')); + const nextPairs = []; + commonPairs.forEach(item => { + const enabled = modal.overlay.querySelector(`[data-common-pair="${item.id}"]`)?.checked; + if (enabled) nextPairs.push({ enabled: true, left: item.left, right: item.right }); + }); + pairRows.forEach(row => { + const left = row.querySelector('[data-left]')?.value.trim() || ''; + const right = row.querySelector('[data-right]')?.value.trim() || ''; + if (left || right) nextPairs.push({ enabled: true, left, right }); + }); + const nextSingles = singleRows.map(row => row.querySelector('[data-single]')?.value.trim() || '').filter(Boolean); + state.ttsFilterEnabled = true; + state.ttsFilterPairs = nextPairs; + state.ttsFilterSingles = nextSingles; + Toast.success('播报过滤设置已保存'); + rerender(); + return true; + }, + }); + + const overlay = modal.overlay; + const pairList = overlay.querySelector('#filterPairList'); + const singleList = overlay.querySelector('#filterSingleList'); + + function renderPairRows() { + pairList.innerHTML = draftPairs.length + ? draftPairs.map((pair, index) => ` +
+ + + +
+ `).join('') + : '
暂无自定义符号对
'; + } + + function renderSingleRows() { + singleList.innerHTML = draftSingles.length + ? draftSingles.map((char, index) => ` +
+ + +
+ `).join('') + : '
暂无自定义单字符
'; + } + + function bindDynamicEvents() { + overlay.querySelectorAll('[data-delete-pair]').forEach(btn => { + btn.addEventListener('click', () => { + draftPairs.splice(Number(btn.dataset.deletePair), 1); + renderPairRows(); + bindDynamicEvents(); + renderAgentIcons(overlay); + }); + }); + overlay.querySelectorAll('[data-delete-single]').forEach(btn => { + btn.addEventListener('click', () => { + draftSingles.splice(Number(btn.dataset.deleteSingle), 1); + renderSingleRows(); + bindDynamicEvents(); + renderAgentIcons(overlay); + }); + }); + } + + overlay.querySelector('#addFilterPairBtn')?.addEventListener('click', () => { + draftPairs.push({ enabled: true, left: '', right: '' }); + renderPairRows(); + bindDynamicEvents(); + renderAgentIcons(overlay); + }); + overlay.querySelector('#addFilterSingleBtn')?.addEventListener('click', () => { + draftSingles.push(''); + renderSingleRows(); + bindDynamicEvents(); + renderAgentIcons(overlay); + }); + + renderPairRows(); + renderSingleRows(); + bindDynamicEvents(); + renderAgentIcons(overlay); +} + +function openCommandConfigModal(state, rerender) { + const baseCommands = AGENT_CONFIG_MOCK.skills.commands; + const customCommands = state.customCommands || []; + const modal = createModal({ + title: '指令配置', + size: 'xl', + confirmText: '确定', + cancelText: '取消', + content: ` +
+
下发设备控制指令由端侧执行,可在这里查看系统指令、编辑已选指令并添加自定义指令类型。
+
+ + + + + + + + + + + + + ${[...baseCommands, ...customCommands].map(item => ` + + + + + + + + + `).join('')} + +
启用指令名称指令说明指令示例参数名称回复模式
${escapeHtml(item.name)}${escapeHtml(item.desc)}${escapeHtml(item.example || `${item.name} 示例`)}${escapeHtml(item.params || 'amount')} + +
+
+
+
+

设置自定义指令

+ +
+
+
+
+ `, + onConfirm: () => { + const rows = Array.from(modal.overlay.querySelectorAll('[data-custom-command-row]')); + const next = rows.map(row => ({ + icon: 'settings-2', + name: row.querySelector('[data-name]')?.value.trim() || '', + desc: row.querySelector('[data-desc]')?.value.trim() || '', + example: row.querySelector('[data-example]')?.value.trim() || '', + params: row.querySelector('[data-params]')?.value.trim() || '', + count: 1, + })).filter(item => item.name); + state.customCommands = next; + Toast.success('自定义指令已保存'); + rerender(); + return true; + }, + }); + + const overlay = modal.overlay; + const list = overlay.querySelector('#customCommandList'); + const draft = (state.customCommands || []).map(item => ({ ...item })); + + function renderRows() { + list.innerHTML = draft.length ? draft.map((item, index) => ` +
+ + + + + +
+ `).join('') : '
暂无自定义指令
'; + } + + function bindCommandEvents() { + overlay.querySelectorAll('[data-delete-command]').forEach(btn => { + btn.addEventListener('click', () => { + draft.splice(Number(btn.dataset.deleteCommand), 1); + renderRows(); + bindCommandEvents(); + renderAgentIcons(overlay); + }); + }); + } + + overlay.querySelector('#addCustomCommandBtn')?.addEventListener('click', () => { + draft.push({ name: '', desc: '', example: '', params: '' }); + renderRows(); + bindCommandEvents(); + renderAgentIcons(overlay); + }); + + renderRows(); + bindCommandEvents(); + renderAgentIcons(overlay); +} + +function openPluginConfigModal(state, rerender) { + const basePlugins = AGENT_CONFIG_MOCK.skills.plugins; + const customPlugins = (state.customPlugins || []).map(item => ({ ...item })); + const modal = createModal({ + title: '插件配置', + size: 'xl', + confirmText: '确定', + cancelText: '取消', + content: ` +
+
插件默认由系统执行,也可以在这里单独增删自定义插件并控制是否启用。
+
+

系统插件

+ ${basePlugins.length} 项 +
+
+ ${basePlugins.map(item => ` + + `).join('')} +
+
+

自定义插件

+ +
+
+
+ `, + onConfirm: () => { + const rows = Array.from(modal.overlay.querySelectorAll('[data-custom-plugin-row]')); + state.customPlugins = rows.map(row => ({ + icon: row.querySelector('[data-icon]')?.value.trim() || 'plug', + name: row.querySelector('[data-name]')?.value.trim() || '', + desc: row.querySelector('[data-desc]')?.value.trim() || '', + status: row.querySelector('[data-status]')?.value.trim() || '系统执行', + })).filter(item => item.name); + Toast.success('插件配置已保存'); + rerender(); + return true; + }, + }); + renderAgentIcons(modal.overlay); + + const overlay = modal.overlay; + const list = overlay.querySelector('#customPluginList'); + const draft = customPlugins; + + function renderRows() { + list.innerHTML = draft.length ? draft.map((item, index) => ` +
+ + + + +
+ +
+ `).join('') : '
暂无自定义插件
'; + } + + function bindRows() { + overlay.querySelectorAll('[data-delete-plugin]').forEach(btn => { + btn.addEventListener('click', () => { + draft.splice(Number(btn.dataset.deletePlugin), 1); + renderRows(); + bindRows(); + renderAgentIcons(overlay); + }); + }); + } + + overlay.querySelector('#addCustomPluginBtn')?.addEventListener('click', () => { + draft.push({ icon: 'plug', name: '', desc: '', status: '系统执行' }); + renderRows(); + bindRows(); + renderAgentIcons(overlay); + }); + + renderRows(); + bindRows(); + renderAgentIcons(overlay); +} + +function openRenameModal(state, rerender) { + if (typeof createModal !== 'function') { + const name = prompt('请输入应用名称', state.app.name); + if (name && name.trim()) { + state.app.name = name.trim(); + rerender(); + } + return; + } + createModal({ + title: '编辑应用名称', + content: ` +
+ + +
+ `, + confirmText: '保存', + cancelText: '取消', + onConfirm: () => { + const input = document.getElementById('renameAgentInput'); + const nextName = input?.value.trim(); + if (!nextName) { + Toast.warning('请输入应用名称'); + return false; + } + state.app.name = nextName; + Toast.success('应用名称已更新'); + rerender(); + return true; + }, + }); + setTimeout(() => document.getElementById('renameAgentInput')?.focus(), 50); +} + +function getAgentStatus(status) { + const map = { + published: { label: '已发布', className: 'published' }, + editing: { label: '编辑中', className: 'editing' }, + draft: { label: '草稿', className: 'draft' }, + }; + return map[status] || map.draft; +} + +function getSelectedScene(state) { + return AGENT_SCENE_TEMPLATES.find(scene => scene.id === state.selectedSceneId) + || AGENT_SCENE_TEMPLATES.find(scene => scene.id === 'toy') + || AGENT_SCENE_TEMPLATES[0]; +} + +function getSelectedSceneVersion(state) { + return AGENT_SCENE_VERSIONS.find(version => version.id === state.selectedSceneVersion) + || AGENT_SCENE_VERSIONS[0]; +} + +function getSelectedSceneLanguage(state) { + return AGENT_SCENE_LANGUAGES.find(language => language.id === state.selectedSceneLanguage) + || AGENT_SCENE_LANGUAGES[0]; +} + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value ?? ''); + return div.innerHTML; +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/"/g, '"').replace(/'/g, '''); +} + +function renderAgentIcons(root) { + if (!root || !window.lucide?.createIcons) return; + try { + lucide.createIcons({ elements: [root] }); + } catch (error) { + console.warn('Agent config icon render failed:', error); + } +} diff --git a/scripts/agent-manage.js b/scripts/agent-manage.js new file mode 100644 index 0000000..2b23e2a --- /dev/null +++ b/scripts/agent-manage.js @@ -0,0 +1,797 @@ +/** + * TalkingQ Platform - Application Management Page + * 应用管理:多模态交互应用 / 语音交互应用 + */ + +function renderAgentManagePage(container, typeFilter = 'all') { + const RECYCLE_RETENTION_DAYS = 7; + const RECYCLE_RETENTION_MS = RECYCLE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const fallbackCompanies = [ + { id: 'company_a', name: '智慧星科技' }, + { id: 'company_b', name: '未来玩具厂' }, + { id: 'company_c', name: '童趣电子' }, + ]; + const appCompanyFallbacks = { + 'app-001': 'company_a', + 'app-002': 'company_b', + 'app-003': 'company_c', + 'app-004': 'company_c', + }; + + function getSelectableAppCompanies() { + const source = window.companies?.length ? window.companies : fallbackCompanies; + return source.filter(company => company.id !== 'all'); + } + + function getAppCompanyName(companyId) { + return window.__getCompanyNameById + ? window.__getCompanyNameById(companyId) + : (getSelectableAppCompanies().find(company => company.id === companyId)?.name || companyId || '未知公司'); + } + + function getDefaultAppCompanyId() { + const companies = getSelectableAppCompanies(); + const currentCompanyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : window.currentCompany; + if (currentCompanyId && currentCompanyId !== 'all' && companies.some(company => company.id === currentCompanyId)) { + return currentCompanyId; + } + return companies[0]?.id || 'company_a'; + } + + function normalizeAppCompany(app) { + const companies = getSelectableAppCompanies(); + const mappedCompanyId = appCompanyFallbacks[app.id]; + const fallbackCompanyId = companies.some(company => company.id === mappedCompanyId) + ? mappedCompanyId + : getDefaultAppCompanyId(); + const companyId = companies.some(company => company.id === app.companyId) ? app.companyId : fallbackCompanyId; + app.companyId = companyId; + app.companyName = getAppCompanyName(companyId); + return app; + } + + // 模拟应用数据 + const defaultApps = [ + { + id: 'app-001', type: 'multimodal', name: '小Q 故事机器人', companyId: 'company_a', + status: 'published', // published | editing | draft + firmwareVersion: 'v2.3.1', firmwareConnected: true, + updatedAt: '2026-05-13 10:24:38', + description: '为 3-10 岁儿童设计的互动故事讲述应用', + dau: 8420 + }, + { + id: 'app-002', type: 'voice', name: '英语学习助手', companyId: 'company_b', + status: 'editing', + firmwareVersion: 'v2.0.2', firmwareConnected: true, + updatedAt: '2026-05-12 15:09:07', + description: '基于对话的英语口语训练应用', + dau: 3120 + }, + { + id: 'app-003', type: 'voice', name: '科学百科探索者', companyId: 'company_c', + status: 'draft', + firmwareVersion: null, firmwareConnected: false, + updatedAt: '2026-05-10 08:52:11', + description: '引导儿童探索科学知识的问答应用', + dau: 0 + }, + { + id: 'app-004', type: 'multimodal', name: '数学小课堂', companyId: 'company_c', + status: 'published', + firmwareVersion: 'v1.8.2', firmwareConnected: true, + updatedAt: '2026-05-08 17:33:55', + description: '趣味数学题与可视化解析', + dau: 2180 + }, + ]; + if (!window.__agentManagedApps) { + window.__agentManagedApps = defaultApps.map(app => normalizeAppCompany({ ...app })); + } + window.__agentManagedApps.forEach(normalizeAppCompany); + let apps = window.__agentManagedApps; + + let currentFilter = 'all'; + let currentTypeFilter = typeFilter || window.__appTypeFilter || 'all'; + window.__appTypeFilter = 'all'; + let searchText = ''; + + function getNowText() { + return new Date().toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).replace(/\//g, '-'); + } + + function getActiveApps() { + return apps.filter(app => !app.deletedAt); + } + + function getRecycledApps() { + return apps + .filter(app => app.deletedAt) + .sort((a, b) => new Date(b.deletedAt) - new Date(a.deletedAt)); + } + + function getRecycleExpireAt(app) { + const deletedTime = new Date(app.deletedAt || Date.now()).getTime(); + return new Date(deletedTime + RECYCLE_RETENTION_MS); + } + + function getRecycleRemainingText(app) { + const remainingMs = getRecycleExpireAt(app).getTime() - Date.now(); + if (remainingMs <= 0) return '待自动清理'; + const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000)); + const hours = Math.ceil((remainingMs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); + if (days <= 0) return `${Math.max(hours, 1)} 小时后清理`; + return `${days} 天 ${hours} 小时后清理`; + } + + function pushAppLifecycleNotification({ title, detail, app, severity = 'info', metric = '应用生命周期' }) { + window.__addRuntimeNotification?.({ + eventType: 'agent.lifecycle', + id: `agent-lifecycle-${Date.now()}-${Math.random().toString(16).slice(2, 7)}`, + title, + detail, + objectName: app?.name || '交互应用', + metric, + severity, + actionPage: app?.type === 'voice' ? 'agent-manage-voice' : app?.type === 'multimodal' ? 'agent-manage-multimodal' : 'agent-manage', + actionText: '查看应用', + }); + } + + function cleanupExpiredRecycledApps({ notify = true } = {}) { + const now = Date.now(); + const expiredApps = apps.filter(app => app.deletedAt && getRecycleExpireAt(app).getTime() <= now); + if (!expiredApps.length) return; + API.agents.cleanupRecycleBin().catch(error => { + console.warn('Recycle bin cleanup failed in mock mode', error); + }); + window.__agentManagedApps = apps = apps.filter(app => !app.deletedAt || getRecycleExpireAt(app).getTime() > now); + if (notify) { + Toast.info(`系统已自动清理 ${expiredApps.length} 个超过 ${RECYCLE_RETENTION_DAYS} 天的回收站应用`, 1800); + pushAppLifecycleNotification({ + title: `回收站已自动清理 ${expiredApps.length} 个过期应用`, + detail: `系统按 ${RECYCLE_RETENTION_DAYS} 天保留策略清理了:${expiredApps.map(app => app.name).join('、')}。后端接入后该操作应写入审计日志,并触发站内消息。`, + app: expiredApps[0], + severity: 'warning', + metric: `${RECYCLE_RETENTION_DAYS} 天保留到期`, + }); + } + } + cleanupExpiredRecycledApps({ notify: true }); + + function getStatusLabel(status) { + const map = { + published: { label: '已发布', cls: 'badge-success' }, + editing: { label: '已发布·编辑中', cls: 'badge-warning' }, + draft: { label: '草稿', cls: 'badge-default' }, + }; + return map[status] || { label: status, cls: 'badge-default' }; + } + + function getTypeInfo(type) { + if (type === 'multimodal') return { label: '多模态交互', icon: 'monitor-smartphone', color: '#722ed1', bg: '#f9f0ff' }; + return { label: '语音交互', icon: 'mic', color: '#1890ff', bg: '#e6f7ff' }; + } + + function renderApps() { + cleanupExpiredRecycledApps({ notify: false }); + const recycleCountEl = document.getElementById('appRecycleCount'); + if (recycleCountEl) recycleCountEl.textContent = getRecycledApps().length; + const filtered = getActiveApps().filter(a => { + const matchFilter = currentFilter === 'all' || a.status === currentFilter; + const matchType = currentTypeFilter === 'all' || a.type === currentTypeFilter; + const matchSearch = !searchText || a.name.includes(searchText) || a.id.includes(searchText); + return matchFilter && matchType && matchSearch; + }); + + const grid = document.getElementById('appGrid'); + if (!grid) return; + updateFilterCounts(); + + if (filtered.length === 0) { + grid.innerHTML = ` +
+ +

暂无符合条件的应用

+

试试调整筛选条件或创建新应用

+
+
+ + 创建新应用 +
`; + lucide.createIcons({ elements: [grid] }); + document.getElementById('createAppCard')?.addEventListener('click', () => { + showCreateAppModal(); + }); + return; + } + + grid.innerHTML = filtered.map(app => { + const st = getStatusLabel(app.status); + const tp = getTypeInfo(app.type); + return ` +
+
+
+ + ${tp.label} +
+ ${st.label} +
+ +
+
+ +
+
+
${app.name}
+
${app.description}
+
+
+ +
+
+ 应用 ID + ${app.id} +
+
+ 所属公司 + ${getAppCompanyName(app.companyId)} +
+
+ 更新时间 + ${app.updatedAt} +
+
+ 固件版本 + + ${app.firmwareConnected ? app.firmwareVersion : '未接入固件'} + +
+
+ +
+ +
+ +
+ + + + +
+
+
+
+ `; + }).join('') + ` + +
+ + 创建新应用 +
+ `; + + lucide.createIcons({ elements: [grid] }); + + // 绑定配置按钮 + grid.querySelectorAll('.app-action-config').forEach(btn => { + btn.addEventListener('click', () => { + const appId = btn.dataset.id; + // 传递选中的 appId + window.__selectedAppId = appId; + navigateTo('agent-config'); + }); + }); + + // 绑定新建卡片 + document.getElementById('createAppCard')?.addEventListener('click', () => { + showCreateAppModal(); + }); + + grid.querySelectorAll('[data-app-menu-trigger]').forEach(btn => { + btn.addEventListener('click', (event) => { + event.stopPropagation(); + const menu = btn.closest('[data-app-menu]'); + const isOpen = menu?.classList.contains('open'); + grid.querySelectorAll('[data-app-menu].open').forEach(item => { + item.classList.remove('open'); + item.querySelector('[data-app-menu-trigger]')?.setAttribute('aria-expanded', 'false'); + }); + if (!isOpen) { + menu?.classList.add('open'); + btn.setAttribute('aria-expanded', 'true'); + } + }); + }); + + grid.querySelectorAll('[data-app-action]').forEach(btn => { + btn.addEventListener('click', (event) => { + event.stopPropagation(); + const action = btn.dataset.appAction; + const appId = btn.dataset.id; + const app = apps.find(item => item.id === appId); + if (!app) return; + btn.closest('[data-app-menu]')?.classList.remove('open'); + + if (action === 'analytics') { + const companyId = normalizeAppCompany(app).companyId; + const role = window.__getUserRole ? window.__getUserRole() : 'admin'; + const allowedCompanyIds = window.__getAllowedCompanyIds ? window.__getAllowedCompanyIds(role) : ['all']; + if (!allowedCompanyIds.includes(companyId)) { + Toast.warning('当前角色无权查看该公司数据'); + return; + } + if (window.switchCompany) { + window.switchCompany(companyId); + } else { + window.currentCompany = companyId; + window.__syncCompanySelectorValue?.(companyId); + } + window.__dashboardAppScope = { id: app.id, name: app.name, type: app.type, companyId, companyName: getAppCompanyName(companyId) }; + navigateTo('dashboard'); + } else if (action === 'api') { + window.showApiIntegrationModal(app.id, app.name); + } else if (action === 'duplicate') { + window.duplicateApp(app.id); + } else if (action === 'delete') { + window.deleteApp(app.id, app.name); + } + }); + }); + + } + + function getTypeScopedApps() { + return getActiveApps().filter(a => currentTypeFilter === 'all' || a.type === currentTypeFilter); + } + + function updateFilterCounts() { + const scoped = getTypeScopedApps(); + document.querySelectorAll('.filter-tab').forEach(tab => { + const filter = tab.dataset.filter; + const countEl = tab.querySelector('.filter-count'); + if (!countEl) return; + countEl.textContent = filter === 'all' + ? scoped.length + : scoped.filter(app => app.status === filter).length; + }); + } + + function setActiveFilter(filter) { + currentFilter = filter; + document.querySelectorAll('.filter-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.filter === filter); + }); + updateFilterCounts(); + } + + function clearAppSearch() { + searchText = ''; + const input = document.getElementById('appSearchInput'); + if (input) input.value = ''; + } + + function revealCreatedApp(app) { + clearAppSearch(); + if (currentTypeFilter !== 'all' && currentTypeFilter !== app.type) { + window.__appTypeFilter = app.type; + setTimeout(() => navigateTo(app.type === 'voice' ? 'agent-manage-voice' : 'agent-manage-multimodal'), 0); + return; + } + setActiveFilter('all'); + renderApps(); + } + + const typeScopedApps = getTypeScopedApps(); + + container.innerHTML = ` + + + +
+
+ + + + +
+ +
+ + +
+ `; + + lucide.createIcons({ elements: [container] }); + renderApps(); + + // 筛选 tab 切换 + document.querySelectorAll('.filter-tab').forEach(tab => { + tab.addEventListener('click', () => { + currentFilter = tab.dataset.filter; + document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + renderApps(); + }); + }); + + // 搜索 + document.getElementById('appSearchInput')?.addEventListener('input', (e) => { + searchText = e.target.value.trim(); + renderApps(); + }); + + // 顶部创建按钮 + document.getElementById('createAppBtn')?.addEventListener('click', showCreateAppModal); + document.getElementById('openAppRecycleBtn')?.addEventListener('click', showRecycleBinModal); + + if (window.__appMenuCloseHandler) { + document.removeEventListener('click', window.__appMenuCloseHandler); + } + window.__appMenuCloseHandler = (event) => { + if (event.target.closest?.('[data-app-menu]')) return; + container.querySelectorAll('[data-app-menu].open').forEach(menu => { + menu.classList.remove('open'); + menu.querySelector('[data-app-menu-trigger]')?.setAttribute('aria-expanded', 'false'); + }); + }; + document.addEventListener('click', window.__appMenuCloseHandler); + + // ─── 工具函数 ──────────────────────────────────────────────── + window.duplicateApp = function(appId) { + const app = apps.find(a => a.id === appId && !a.deletedAt); + if (!app) return; + const newApp = { + ...app, + id: 'app-' + String(Date.now()).slice(-3), + name: app.name + '(副本)', + status: 'draft', + firmwareConnected: false, + firmwareVersion: null, + updatedAt: new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'), + dau: 0, + deletedAt: null, + deletedBy: null, + }; + apps.push(newApp); + revealCreatedApp(newApp); + Toast.success(`已复制"${app.name}"为草稿`); + }; + + window.deleteApp = function(appId, appName) { + const app = apps.find(a => a.id === appId && !a.deletedAt); + if (!app) return; + createModal({ + title: '移入回收站', + content: ` +

确定要将应用 ${appName} 移入回收站吗?

+
+ +
+ 保留 ${RECYCLE_RETENTION_DAYS} 天 + 移入回收站后应用会从正常列表隐藏,${RECYCLE_RETENTION_DAYS} 天内可恢复;到期后系统自动清理。该操作会在消息通知中提醒管理员。 +
+
+ `, + confirmText: '移入回收站', + onConfirm: async () => { + try { + await API.agents.delete(appId); + } catch (error) { + Toast.error('应用移入回收站失败,请稍后重试'); + return false; + } + app.deletedAt = new Date().toISOString(); + app.deletedBy = sessionStorage.getItem('talkingq_user') || 'Admin'; + app.updatedAt = getNowText(); + renderApps(); + Toast.success(`应用"${appName}"已移入回收站,${RECYCLE_RETENTION_DAYS} 天后自动清理`); + pushAppLifecycleNotification({ + title: `应用「${appName}」已移入回收站`, + detail: `应用「${appName}」已从正常应用列表隐藏,将在 ${getRecycleExpireAt(app).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-')} 后自动清理。${RECYCLE_RETENTION_DAYS} 天内可在应用管理回收站恢复。`, + app, + severity: 'warning', + metric: `${RECYCLE_RETENTION_DAYS} 天后自动清理`, + }); + } + }); + }; + + function showRecycleBinModal() { + cleanupExpiredRecycledApps({ notify: true }); + const recycledApps = getRecycledApps(); + const modal = createModal({ + title: `应用回收站(保留 ${RECYCLE_RETENTION_DAYS} 天)`, + size: 'large', + showCancel: false, + confirmText: '关闭', + content: ` +
+
+
+ ${recycledApps.length} + 个应用等待清理 +
+

回收站仅保留 ${RECYCLE_RETENTION_DAYS} 天。恢复后应用会回到原公司和原类型列表;彻底删除后前端 mock 中不再可恢复。

+
+ ${recycledApps.length === 0 ? ` +
+ +

回收站为空

+

被删除的交互应用会在这里保留 ${RECYCLE_RETENTION_DAYS} 天。

+
+ ` : ` +
+ ${recycledApps.map(app => { + const tp = getTypeInfo(app.type); + const deletedAt = new Date(app.deletedAt).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-'); + const expireAt = getRecycleExpireAt(app).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-'); + return ` +
+
+
+ +
+
+
${app.name}
+
+ ${tp.label} + ${getAppCompanyName(app.companyId)} + ID: ${app.id} +
+
+ 删除时间:${deletedAt} · 到期清理:${expireAt} · ${getRecycleRemainingText(app)} +
+
+
+
+ + +
+
+ `; + }).join('')} +
+ `} +
+ `, + onConfirm: () => true, + }); + + setTimeout(() => { + lucide.createIcons({ elements: [modal.overlay] }); + modal.overlay.querySelectorAll('[data-recycle-action]').forEach(btn => { + btn.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + const app = apps.find(item => item.id === btn.dataset.id && item.deletedAt); + if (!app) return; + if (btn.dataset.recycleAction === 'restore') { + btn.disabled = true; + try { + await API.agents.restore(app.id); + } catch (error) { + Toast.error('应用恢复失败,请稍后重试'); + btn.disabled = false; + return; + } + app.deletedAt = null; + app.deletedBy = null; + app.updatedAt = getNowText(); + modal.close(); + renderApps(); + Toast.success(`应用"${app.name}"已恢复`); + pushAppLifecycleNotification({ + title: `应用「${app.name}」已从回收站恢复`, + detail: `应用「${app.name}」已恢复到 ${getAppCompanyName(app.companyId)} 的${getTypeInfo(app.type).label}列表,可继续配置、发布和查看数据。`, + app, + severity: 'success', + metric: '已恢复', + }); + } + if (btn.dataset.recycleAction === 'purge') { + createModal({ + title: '彻底删除应用', + content: `

确定要彻底删除 ${app.name} 吗?

彻底删除后将从回收站移除,前端 mock 中不可恢复。后端接入时应同步删除应用配置或进入异步清理任务。

`, + confirmText: '彻底删除', + onConfirm: async () => { + try { + await API.agents.purge(app.id); + } catch (error) { + Toast.error('应用彻底删除失败,请稍后重试'); + return false; + } + const deletedName = app.name; + const deletedApp = { ...app }; + window.__agentManagedApps = apps = apps.filter(item => item.id !== app.id); + modal.close(); + renderApps(); + Toast.success(`应用"${deletedName}"已彻底删除`); + pushAppLifecycleNotification({ + title: `应用「${deletedName}」已彻底删除`, + detail: `应用「${deletedName}」已从回收站中彻底删除。后端接入后建议保留审计日志,并异步清理配置、运行记录索引和关联缓存。`, + app: deletedApp, + severity: 'critical', + metric: '不可恢复', + }); + } + }); + } + }); + }); + }, 50); + } + + window.showApiIntegrationModal = function(appId, appName) { + createModal({ + title: `API 接入 — ${appName}`, + content: ` +
+
App ID
+
+ ${appId} + +
+
+
+
API Key
+
+ sk-tq-•••••••••••••••••••••••• + +
+
+
+ +
访问文档:POST https://api.talkingq.com/v1/chat/completions,Header 携带 Authorization: Bearer <API_KEY>
+
+ `, + }); + setTimeout(() => lucide.createIcons(), 100); + }; + + function showCreateAppModal() { + const defaultType = currentTypeFilter === 'voice' ? 'voice' : 'multimodal'; + const selectableCompanies = getSelectableAppCompanies(); + const defaultCompanyId = getDefaultAppCompanyId(); + createModal({ + title: '创建应用', + content: ` +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ `, + onConfirm: () => { + const name = document.getElementById('newAppName')?.value.trim(); + const desc = document.getElementById('newAppDesc')?.value.trim(); + const type = document.querySelector('input[name="appType"]:checked')?.value || 'multimodal'; + const companyId = document.getElementById('newAppCompany')?.value; + if (!name) { Toast.warning('请填写应用名称'); return false; } + if (!companyId) { Toast.warning('请选择所属公司'); return false; } + const newApp = { + id: 'app-' + String(Date.now()).slice(-4), + type, name, companyId, companyName: getAppCompanyName(companyId), description: desc || '暂无描述', + status: 'draft', + firmwareVersion: null, firmwareConnected: false, + updatedAt: new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'), + dau: 0, + }; + apps.push(newApp); + revealCreatedApp(newApp); + Toast.success(`应用"${name}"创建成功`); + }, + confirmText: '创建', + }); + + // 等 DOM 渲染后绑定事件 + setTimeout(() => { + lucide.createIcons(); + document.querySelectorAll('.app-type-selector').forEach(el => { + el.addEventListener('click', () => { + document.querySelectorAll('.app-type-selector').forEach(s => s.classList.remove('selected')); + el.classList.add('selected'); + el.querySelector('input[type=radio]').checked = true; + }); + }); + }, 50); + } +} + +registerPage('agent-manage', function(container) { + renderAgentManagePage(container, window.__appTypeFilter || 'all'); +}); + +window.__agentManageRender = renderAgentManagePage; + +registerPage('agent-manage-multimodal', function(container) { + renderAgentManagePage(container, 'multimodal'); +}); + +registerPage('agent-manage-voice', function(container) { + renderAgentManagePage(container, 'voice'); +}); diff --git a/scripts/api.js b/scripts/api.js new file mode 100644 index 0000000..d997708 --- /dev/null +++ b/scripts/api.js @@ -0,0 +1,294 @@ +/** + * TalkingQ Platform - API Interface Layer + * All API calls are defined here as stubs. + * Replace BASE_URL and implement fetch calls to connect backend. + */ + +const API = { + BASE_URL: '/api/v1', // TODO: Replace with actual backend URL + + // ─── Auth ─────────────────────────────────────────────────────── + auth: { + login: (credentials) => API._post('/auth/login', credentials), + logout: () => API._post('/auth/logout'), + me: () => API._get('/auth/me'), + getPermissions: () => API._get('/auth/permissions'), + refreshToken: () => API._post('/auth/refresh'), + }, + + // ─── Dashboard ────────────────────────────────────────────────── + dashboard: { + getOverview: (params) => API._get('/dashboard/overview', params), + getDAUTrend: (params) => API._get('/dashboard/dau-trend', params), + getSessionStats: (params) => API._get('/dashboard/sessions', params), + getTurnsPerSession: (params) => API._get('/dashboard/turns', params), + getActivationRate: (params) => API._get('/dashboard/activation', params), + getRetentionCohort: (params) => API._get('/dashboard/retention', params), + getIntentDistribution: (params) => API._get('/dashboard/intents', params), + getLanguageStats: (params) => API._get('/dashboard/languages', params), + }, + + // ─── Agent Development ────────────────────────────────────────── + agentDev: { + // ASR (Automatic Speech Recognition) + getASRProviders: () => API._get('/agent-dev/asr/providers'), + getASRConfig: (agentId) => API._get(`/agent-dev/${agentId}/asr`), + updateASRConfig: (agentId, config) => API._put(`/agent-dev/${agentId}/asr`, config), + testASR: (agentId, audioBlob) => API._post(`/agent-dev/${agentId}/asr/test`, { audio: audioBlob }), + getHotwordGroups: (params) => API._get('/agent-dev/asr/hotword-groups', params), + createHotwordGroup: (data) => API._post('/agent-dev/asr/hotword-groups', data), + updateHotwordGroup: (id, data) => API._put(`/agent-dev/asr/hotword-groups/${id}`, data), + deleteHotwordGroup: (id) => API._delete(`/agent-dev/asr/hotword-groups/${id}`), + getAgentHotwordGroups: (agentId) => API._get(`/agent-dev/${agentId}/asr/hotword-groups`), + bindHotwordGroups: (agentId, groupIds) => API._put(`/agent-dev/${agentId}/asr/hotword-groups`, { groupIds }), + + // TTS (Text-to-Speech) + getTTSProviders: () => API._get('/agent-dev/tts/providers'), + getTTSConfig: (agentId) => API._get(`/agent-dev/${agentId}/tts`), + updateTTSConfig: (agentId, config) => API._put(`/agent-dev/${agentId}/tts`, config), + testTTS: (agentId, text) => API._post(`/agent-dev/${agentId}/tts/test`, { text }), + previewVoice: (provider, voiceId, text) => API._post('/agent-dev/tts/preview', { provider, voiceId, text }), + + // RAG (Retrieval Augmented Generation) + getKnowledgeBases: () => API._get('/agent-dev/rag/knowledge-bases'), + getAgentRAG: (agentId) => API._get(`/agent-dev/${agentId}/rag`), + attachKnowledgeBase: (agentId, kbId) => API._post(`/agent-dev/${agentId}/rag/attach`, { kbId }), + detachKnowledgeBase: (agentId, kbId) => API._delete(`/agent-dev/${agentId}/rag/detach/${kbId}`), + createKnowledgeBase: (data) => API._post('/agent-dev/rag/knowledge-bases', data), + uploadDocument: (kbId, file) => API._upload(`/agent-dev/rag/knowledge-bases/${kbId}/docs`, file), + deleteDocument: (kbId, docId) => API._delete(`/agent-dev/rag/knowledge-bases/${kbId}/docs/${docId}`), + testRAGQuery: (agentId, query) => API._post(`/agent-dev/${agentId}/rag/test`, { query }), + + // Language + getSupportedLanguages: () => API._get('/agent-dev/languages'), + getAgentLanguages: (agentId) => API._get(`/agent-dev/${agentId}/languages`), + updateAgentLanguages: (agentId, langs) => API._put(`/agent-dev/${agentId}/languages`, { languages: langs }), + setPrimaryLanguage: (agentId, lang) => API._put(`/agent-dev/${agentId}/languages/primary`, { language: lang }), + + // General agent config + saveConfig: (agentId, config) => API._put(`/agent-dev/${agentId}/config`, config), + deployAgent: (agentId, data = {}) => API._post(`/agent-dev/${agentId}/deploy`, data), + getDeployStatus: (agentId) => API._get(`/agent-dev/${agentId}/deploy/status`), + }, + + // ─── Agent Management ─────────────────────────────────────────── + agents: { + list: (params) => API._get('/agents', params), + get: (id) => API._get(`/agents/${id}`), + create: (data) => API._post('/agents', data), + update: (id, data) => API._put(`/agents/${id}`, data), + delete: (id) => API._post(`/agents/${id}/recycle`, { retentionDays: 7 }), + listRecycleBin: (params) => API._get('/agents/recycle-bin', params), + restore: (id) => API._post(`/agents/${id}/restore`), + purge: (id) => API._delete(`/agents/${id}`), + cleanupRecycleBin: () => API._post('/agents/recycle-bin/cleanup'), + getVersions: (id) => API._get(`/agents/${id}/versions`), + rollback: (id, version) => API._post(`/agents/${id}/rollback`, { version }), + }, + + // ─── Prompt Management ────────────────────────────────────────── + prompts: { + list: (params) => API._get('/prompts', params), + get: (id) => API._get(`/prompts/${id}`), + create: (data) => API._post('/prompts', data), + update: (id, data) => API._put(`/prompts/${id}`, data), + delete: (id) => API._delete(`/prompts/${id}`), + publish: (id) => API._post(`/prompts/${id}/publish`), + getVersions: (id) => API._get(`/prompts/${id}/versions`), + startABTest: (data) => API._post('/prompts/ab-test', data), + getABTestResults: (testId) => API._get(`/prompts/ab-test/${testId}/results`), + }, + + // ─── Products ─────────────────────────────────────────────────── + products: { + list: (params) => API._get('/products', params), + get: (id) => API._get(`/products/${id}`), + create: (data) => API._post('/products', data), + update: (id, data) => API._put(`/products/${id}`, data), + delete: (id) => API._delete(`/products/${id}`), + }, + + // ─── Tenant / Company Scope ───────────────────────────────────── + tenants: { + listCompanies: (params) => API._get('/tenants/companies', params), + createCompany: (data) => API._post('/tenants/companies', data), + updateCompany: (companyId, data) => API._put(`/tenants/companies/${companyId}`, data), + deleteCompany: (companyId) => API._delete(`/tenants/companies/${companyId}`), + deleteCompanyWithTransfer: (companyId, data = {}) => API._post(`/tenants/companies/${companyId}/delete`, data), + getMyCompany: () => API._get('/tenants/me/company'), + getMyScope: () => API._get('/tenants/me/scope'), + switchCompany: (companyId) => API._post('/tenants/switch-company', { companyId }), + }, + + // ─── Devices ──────────────────────────────────────────────────── + devices: { + list: (params) => API._get('/devices', params), + get: (id) => API._get(`/devices/${id}`), + create: (data) => API._post('/devices', data), + batchCreate: (data) => API._post('/devices/batch', data), + validateBatch: (data) => API._post('/devices/batch/validate', data), + importFile: (file, meta) => API._upload('/devices/import', file, meta), + update: (id, data) => API._put(`/devices/${id}`, data), + delete: (id) => API._delete(`/devices/${id}`), + getHeartbeat: (id) => API._get(`/devices/${id}/heartbeat`), + reboot: (id) => API._post(`/devices/${id}/reboot`), + getLogs: (id, params) => API._get(`/devices/${id}/logs`, params), + bindProduct: (id, productId) => API._post(`/devices/${id}/bind`, { productId }), + unbind: (id) => API._post(`/devices/${id}/unbind`), + export: (params) => API._get('/devices/export', params), + }, + + // ─── OTA ──────────────────────────────────────────────────────── + ota: { + list: (params) => API._get('/ota/tasks', params), + get: (id) => API._get(`/ota/tasks/${id}`), + create: (data) => API._post('/ota/tasks', data), + cancel: (id) => API._post(`/ota/tasks/${id}/cancel`), + resume: (id) => API._post(`/ota/tasks/${id}/resume`), + getProgress: (id) => API._get(`/ota/tasks/${id}/progress`), + getFirmwares: (params) => API._get('/ota/firmwares', params), + getFirmwaresByProduct: (productId) => API._get('/ota/firmwares', { productId }), + uploadFirmware: (file, meta) => API._upload('/ota/firmwares', file, meta), + setLatestFirmware: (id, data) => API._post(`/ota/firmwares/${id}/set-latest`, data), + setLatestAndCreateTask: (id, data) => API._post(`/ota/firmwares/${id}/set-latest-and-upgrade`, data), + deleteFirmware: (id) => API._delete(`/ota/firmwares/${id}`), + upgradeSingle: (deviceId, data) => API._post('/ota/single', { device_id: deviceId, ...data }), + }, + + // ─── Sessions ─────────────────────────────────────────────────── + sessions: { + list: (params) => API._get('/sessions', params), + get: (id) => API._get(`/sessions/${id}`), + getMessages: (id) => API._get(`/sessions/${id}/messages`), + replay: (id) => API._get(`/sessions/${id}/replay`), + export: (params) => API._get('/sessions/export', params), + getStats: (params) => API._get('/sessions/stats', params), + }, + + // ─── Bad Cases ────────────────────────────────────────────────── + badCases: { + list: (params) => API._get('/bad-cases', params), + get: (id) => API._get(`/bad-cases/${id}`), + tag: (id, tags) => API._put(`/bad-cases/${id}/tags`, { tags }), + review: (id, data) => API._post(`/bad-cases/${id}/review`, data), + close: (id, resolution) => API._post(`/bad-cases/${id}/close`, { resolution }), + export: (params) => API._get('/bad-cases/export', params), + getStats: (params) => API._get('/bad-cases/stats', params), + }, + + // ─── Notifications ────────────────────────────────────────────── + notifications: { + // Backend should return tenant/company-scoped notifications only. + // Notification item shape: + // { + // id, eventType, category, severity, title, detail, objectType, + // objectId, companyId, companyName, metric, read, createdAt, actionPage + // } + list: (params) => API._get('/notifications', params), + get: (id) => API._get(`/notifications/${id}`), + getCatalog: () => API._get('/notifications/catalog'), + create: (data) => API._post('/notifications', data), + markRead: (id) => API._post(`/notifications/${id}/read`), + markAllRead: (params) => API._post('/notifications/read-all', params), + archive: (id) => API._post(`/notifications/${id}/archive`), + getStats: (params) => API._get('/notifications/stats', params), + }, + + // ─── RBAC ─────────────────────────────────────────────────────── + rbac: { + getUsers: (params) => API._get('/rbac/users', params), + createUser: (data) => API._post('/rbac/users', data), + updateUser: (id, data) => API._put(`/rbac/users/${id}`, data), + deleteUser: (id) => API._delete(`/rbac/users/${id}`), + getRoles: () => API._get('/rbac/roles'), + getCurrentPermissions: () => API._get('/rbac/me/permissions'), + createRole: (data) => API._post('/rbac/roles', data), + updateRole: (id, data) => API._put(`/rbac/roles/${id}`, data), + deleteRole: (id) => API._delete(`/rbac/roles/${id}`), + getPermissions: () => API._get('/rbac/permissions'), + assignRole: (userId, roleId) => API._post('/rbac/users/assign-role', { userId, roleId }), + getAuditLogs: (params) => API._get('/rbac/audit-logs', params), + }, + + // ─── Privacy / PII ────────────────────────────────────────────── + privacy: { + getConfig: () => API._get('/privacy/config'), + updateConfig: (data) => API._put('/privacy/config', data), + getRules: () => API._get('/privacy/pii-rules'), + createRule: (data) => API._post('/privacy/pii-rules', data), + updateRule: (id, data) => API._put(`/privacy/pii-rules/${id}`, data), + deleteRule: (id) => API._delete(`/privacy/pii-rules/${id}`), + getDataRequests: (params) => API._get('/privacy/data-requests', params), + processDataRequest: (id, action) => API._post(`/privacy/data-requests/${id}/${action}`), + getRetentionPolicies: () => API._get('/privacy/retention-policies'), + updateRetentionPolicy: (id, data) => API._put(`/privacy/retention-policies/${id}`, data), + }, + + // ─── Settings ─────────────────────────────────────────────────── + settings: { + getTenantInfo: () => API._get('/settings/tenant'), + updateTenantInfo: (data) => API._put('/settings/tenant', data), + getNotifications: () => API._get('/settings/notifications'), + // Notification settings shape: + // { + // rules: { + // [eventType]: { enabled, threshold?, thresholdUnit?, channels: ['in_app','email','webhook'] } + // } + // } + updateNotifications: (data) => API._put('/settings/notifications', data), + getIntegrations: () => API._get('/settings/integrations'), + testWebhook: (url) => API._post('/settings/webhooks/test', { url }), + getApiKeys: () => API._get('/settings/api-keys'), + createApiKey: (data) => API._post('/settings/api-keys', data), + revokeApiKey: (id) => API._delete(`/settings/api-keys/${id}`), + }, + + // ─── Internal helpers ─────────────────────────────────────────── + _headers() { + const token = localStorage.getItem('tq_token'); + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }; + }, + + async _request(method, path, body, params) { + // TODO: Replace with actual fetch when backend is ready + // Simulated delay for demo + await new Promise(r => setTimeout(r, 150 + Math.random() * 100)); + // Return mock success + return { success: true, data: null, message: 'OK (mock)' }; + + /* Real implementation (uncomment when backend ready): + const url = new URL(API.BASE_URL + path, window.location.origin); + if (params) Object.entries(params).forEach(([k,v]) => v != null && url.searchParams.set(k, v)); + const res = await fetch(url, { + method, + headers: API._headers(), + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); + */ + }, + + _get: (path, params) => API._request('GET', path, null, params), + _post: (path, body) => API._request('POST', path, body), + _put: (path, body) => API._request('PUT', path, body), + _delete: (path) => API._request('DELETE', path), + + async _upload(path, file, meta = {}) { + // TODO: Implement multipart upload when backend ready + await new Promise(r => setTimeout(r, 300)); + return { success: true, data: { fileId: 'mock-file-id', filename: file?.name }, message: 'OK (mock)' }; + }, +}; + +// Global error handler +window.addEventListener('unhandledrejection', e => { + if (e.reason?.message?.includes('401')) { + // Redirect to login + console.warn('Session expired'); + } +}); diff --git a/scripts/app.js b/scripts/app.js new file mode 100644 index 0000000..32c7f4c --- /dev/null +++ b/scripts/app.js @@ -0,0 +1,265 @@ +/** + * TalkingQ Platform - App Core + * Router, sidebar, topbar clock, toast notifications + */ + +// ─── Toast ────────────────────────────────────────────────────────────────── +const Toast = (() => { + let container = null; + function getContainer() { + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + } + return container; + } + + function show(message, type = 'success', duration = 3000) { + const icons = { success: 'check-circle', error: 'x-circle', warning: 'alert-triangle', info: 'info' }; + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` + + ${message} + `; + getContainer().appendChild(toast); + lucide.createIcons({ elements: [toast] }); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.3s'; + setTimeout(() => toast.remove(), 300); + }, duration); + } + + return { show, + success: (m, duration) => show(m, 'success', duration), + error: (m, duration) => show(m, 'error', duration), + warning: (m, duration) => show(m, 'warning', duration), + info: (m, duration) => show(m, 'info', duration), + }; +})(); + +// ─── Router ────────────────────────────────────────────────────────────────── +const pageNames = { + 'login': '登录', + 'dashboard': '数据仪表盘', + 'agent-dev': '对话应用配置', + 'agent-manage': '应用管理', + 'device': '设备管理', + 'ota': 'OTA 升级', + 'session': '会话详情', + 'badcase': 'Bad Case 管理', + 'rbac': '权限管理 (RBAC)', + 'privacy': '隐私保护 (PII)', + 'settings': '系统设置', + 'knowledge-base': '知识库管理', + 'kb-create': '创建知识库', + 'kb-detail': '知识库详情', +}; + +const pageRenderers = {}; + +function registerPage(id, renderer) { + pageRenderers[id] = renderer; +} + +function navigateTo(pageId) { + // Auth check - login page doesn't need auth + if (pageId !== 'login' && !checkAuth()) { + return; + } + + const sidebar = document.getElementById('sidebar'); + const topbar = document.querySelector('.topbar'); + const mainContent = document.getElementById('mainContent'); + const container = document.getElementById('pageContainer'); + + // Handle login page - full screen, hide sidebar and topbar + if (pageId === 'login') { + document.body.classList.add('login-mode'); + + if (pageRenderers[pageId]) { + container.innerHTML = `
`; + pageRenderers[pageId](document.getElementById(`page-${pageId}`)); + } + return; + } + + // Restore normal layout for other pages + document.body.classList.remove('login-mode'); + + // Update nav + document.querySelectorAll('.nav-item').forEach(el => { + el.classList.toggle('active', el.dataset.page === pageId); + }); + + // Update breadcrumb + const breadcrumb = document.getElementById('breadcrumb'); + if (breadcrumb) breadcrumb.textContent = pageNames[pageId] || pageId; + + // Render page + if (!container) return; + + if (pageRenderers[pageId]) { + container.innerHTML = `
`; + pageRenderers[pageId](document.getElementById(`page-${pageId}`)); + } else { + container.innerHTML = ` +
+
+ +

功能开发中

+

该页面正在建设中,敬请期待

+
+
`; + lucide.createIcons(); + } +} + +// ─── Topbar Clock ──────────────────────────────────────────────────────────── +function updateClock() { + const el = document.getElementById('topbarTime'); + if (!el) return; + const now = new Date(); + el.textContent = now.toLocaleString('zh-CN', { + month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false + }); +} + +// ─── Sidebar Toggle ────────────────────────────────────────────────────────── +function initSidebar() { + const sidebar = document.getElementById('sidebar'); + const toggleBtn = document.getElementById('sidebarToggle'); + const menuBtn = document.getElementById('menuBtn'); + + toggleBtn?.addEventListener('click', () => { + sidebar.classList.toggle('collapsed'); + }); + + menuBtn?.addEventListener('click', () => { + if (window.innerWidth <= 768) { + sidebar.classList.toggle('mobile-open'); + } else { + sidebar.classList.toggle('collapsed'); + } + }); +} + +// ─── Nav Click ─────────────────────────────────────────────────────────────── +function initNav() { + document.querySelectorAll('.nav-item[data-page]').forEach(el => { + el.addEventListener('click', e => { + e.preventDefault(); + navigateTo(el.dataset.page); + if (window.innerWidth <= 768) { + document.getElementById('sidebar')?.classList.remove('mobile-open'); + } + }); + }); +} + +// ─── Modal Helper ──────────────────────────────────────────────────────────── +function createModal({ title, content, onConfirm, confirmText = '确认', cancelText = '取消', size = '', showCancel = true }) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + const sizeClass = size ? ` ${String(size).startsWith('modal-') ? size : `modal-${size}`}` : ''; + overlay.innerHTML = ` +
+ + + +
`; + document.body.appendChild(overlay); + lucide.createIcons({ elements: [overlay] }); + + const close = () => { + overlay.classList.remove('open'); + setTimeout(() => overlay.remove(), 200); + }; + + overlay.querySelector('.modal-close').addEventListener('click', close); + overlay.querySelector('.cancel-btn')?.addEventListener('click', close); + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + + if (onConfirm) { + overlay.querySelector('.confirm-btn').addEventListener('click', async () => { + const confirmBtn = overlay.querySelector('.confirm-btn'); + confirmBtn.disabled = true; + try { + const result = await onConfirm(overlay.querySelector('.modal-body')); + if (result === false) return; + close(); + } finally { + if (document.body.contains(overlay)) confirmBtn.disabled = false; + } + }); + } + + requestAnimationFrame(() => overlay.classList.add('open')); + return { overlay, close }; +} + +// ─── Pagination Helper ─────────────────────────────────────────────────────── +function renderPagination(container, { total, page, pageSize, onChange }) { + const totalPages = Math.ceil(total / pageSize); + container.innerHTML = ` +
+ 共 ${total} 条 + + ${page} / ${totalPages} + +
`; + container.querySelectorAll('[data-page]').forEach(btn => { + btn.addEventListener('click', () => !btn.disabled && onChange(parseInt(btn.dataset.page))); + }); +} + +// ─── Init ──────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + initSidebar(); + initNav(); + updateClock(); + setInterval(updateClock, 1000); + lucide.createIcons(); + + // Check auth and navigate + const loggedIn = sessionStorage.getItem('talkingq_logged_in'); + const targetPage = loggedIn === 'true' ? 'dashboard' : 'login'; + + // Use a small delay to ensure DOM is fully ready + setTimeout(() => { + navigateTo(targetPage); + }, 50); +}); + +// Auth check helper +function checkAuth() { + return sessionStorage.getItem('talkingq_logged_in') === 'true'; +} + +// Logout helper +function doLogout() { + sessionStorage.removeItem('talkingq_user'); + sessionStorage.removeItem('talkingq_logged_in'); + Toast.info('已退出登录'); + setTimeout(() => { + navigateTo('login'); + }, 300); +} + +window.Toast = Toast; +window.navigateTo = navigateTo; +window.registerPage = registerPage; +window.createModal = createModal; +window.renderPagination = renderPagination; +window.checkAuth = checkAuth; diff --git a/scripts/badcase.js b/scripts/badcase.js new file mode 100644 index 0000000..c1242c6 --- /dev/null +++ b/scripts/badcase.js @@ -0,0 +1,273 @@ +/** + * TalkingQ Platform - Bad Case Management Page + */ + +registerPage('badcase', function(container) { + const mockCases = [ + { id: 'BC-2026-001', session: 'sess-6c1d', device: 'DEV-002', agent: '小Q故事机器人', issue: '回答偏离话题,讲了不适合儿童的内容片段', severity: 'high', status: 'open', reporter: '自动检测', time: '2026-05-12 14:07', tags: ['内容安全', '主题偏离'] }, + { id: 'BC-2026-002', session: 'sess-5a4b', device: 'DEV-003', agent: '小Q故事机器人', issue: '粤语识别错误率高,多次识别成普通话', severity: 'medium', status: 'reviewing', reporter: '运营标注', time: '2026-05-12 13:58', tags: ['ASR质量', '粤语'] }, + { id: 'BC-2026-003', session: 'sess-3f7a', device: 'DEV-007', agent: '英语学习助手', issue: '用户问"what is fire",AI回复了如何生火的危险操作', severity: 'critical', status: 'open', reporter: '自动检测', time: '2026-05-12 11:30', tags: ['内容安全', '危险信息'] }, + { id: 'BC-2026-004', session: 'sess-2c8b', device: 'DEV-012', agent: '科学百科探索者', issue: 'TTS 合成失败,只播放了半句话', severity: 'low', status: 'closed', reporter: '系统监控', time: '2026-05-11 09:15', tags: ['TTS故障'] }, + { id: 'BC-2026-005', session: 'sess-1d9e', device: 'DEV-015', agent: '小Q故事机器人', issue: '对用户"我想要妈妈"的情感求助回应过于机械', severity: 'medium', status: 'reviewing', reporter: '人工标注', time: '2026-05-11 16:42', tags: ['情感质量', '儿童关怀'] }, + ]; + + const sevMap = { critical: { label: '严重', badge: 'badge-danger' }, high: { label: '高', badge: 'badge-danger' }, medium: { label: '中', badge: 'badge-warning' }, low: { label: '低', badge: 'badge-default' } }; + const statMap = { open: { label: '待处理', badge: 'badge-danger' }, reviewing: { label: '审核中', badge: 'badge-warning' }, closed: { label: '已关闭', badge: 'badge-default' } }; + + container.innerHTML = ` + + + +
+
+
+
待处理
+
23
+
其中严重 3 个
+
+
+
+
审核中
+
18
+
平均处理 2.3h
+
+
+
+
本月已关闭
+
142
+
解决率 86%
+
+
+
+
内容安全触发
+
7
+
需优先处理
+
+
+ + +
+
+ + +
+ + + +
+ + +
+
+ + + + + + + + + + + + + + + + ${mockCases.map(c => ` + + + + + + + + + + + + `).join('')} + +
ID问题描述智能体严重度状态标签报告人时间操作
${c.id} +
${c.issue}
+
Session: ${c.session} · ${c.device}
+
${c.agent}${sevMap[c.severity]?.label}${statMap[c.status]?.label} +
+ ${c.tags.map(t => `${t}`).join('')} +
+
${c.reporter}${c.time} +
+ + ${c.status !== 'closed' ? ` + + + ` : ''} +
+
+
+
+ `; + + lucide.createIcons({ elements: [container] }); + + window.openBCDetail = (id) => { + const bc = mockCases.find(c => c.id === id); + if (!bc) return; + createModal({ + title: `Bad Case 详情 - ${id}`, + content: ` +
+
问题描述
+
${bc.issue}
+
+
+
+
严重度
+ ${sevMap[bc.severity]?.label} +
+
+
关联 Session
+ ${bc.session} +
+
+
+ + +
+
+ + +
+
+ + 处理后通过 POST /api/v1/bad-cases/${id}/review 提交审核结果 +
`, + onConfirm: async () => { + await API.badCases.review(id, { resolution: 'fixed' }); + Toast.success('已提交处理意见'); + }, + confirmText: '提交处理', + }); + }; + + window.openBCTagModal = (id) => { + const bc = mockCases.find(c => c.id === id); + if (!bc) return; + createModal({ + title: `标注问题 - ${id}`, + content: ` +
+ + +
+
+ + +
`, + onConfirm: () => { + const tags = document.getElementById('bcTagsInput')?.value.trim(); + if (!tags) { + Toast.warning('请填写至少一个问题标签'); + return false; + } + bc.tags = tags.split(',').map(t => t.trim()).filter(Boolean); + Toast.success('问题标注已保存'); + }, + confirmText: '保存标注', + }); + }; + + window.closeBC = (id, btn) => { + const bc = mockCases.find(c => c.id === id); + if (!bc) return; + createModal({ + title: '关闭 Bad Case', + content: ` +

确认关闭 ${id}

+
+ + +
`, + onConfirm: () => { + bc.status = 'closed'; + const row = btn?.closest('tr'); + const statusCell = row?.querySelector('td:nth-child(5)'); + if (statusCell) statusCell.innerHTML = '已关闭'; + const actions = btn?.closest('div'); + if (actions) actions.innerHTML = ``; + lucide.createIcons(); + Toast.success(`${id} 已关闭`); + }, + confirmText: '确认关闭', + }); + }; + + document.getElementById('manualBCBtn')?.addEventListener('click', () => { + createModal({ + title: '手动添加 Bad Case', + content: ` +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
`, + onConfirm: async () => { + const issue = document.getElementById('manualBCIssue')?.value.trim(); + if (!issue) { + Toast.warning('请填写问题描述'); + return false; + } + Toast.success('Bad Case 已添加'); + }, + confirmText: '添加', + }); + }); +}); diff --git a/scripts/dashboard.js b/scripts/dashboard.js new file mode 100644 index 0000000..429a107 --- /dev/null +++ b/scripts/dashboard.js @@ -0,0 +1,1050 @@ +/** + * TalkingQ Platform - Dashboard Page + */ + +// 按公司统计的数据模型 +const companyStats = { + 'all': { + name: '全部公司', + kpi: { + dau: 12847, + dauChange: 8.3, + sessions: 38204, + sessionsChange: 5.1, + devices: 9321, + devicesChange: 120, + totalSessions: 485203, + totalChange: 2.1, + avgTurns: 7.4, + turnsChange: 0.3, + badcaseRate: 1.2, + badcaseChange: -0.3, + }, + // DAU趋势数据 (30天) + dauData: [12000, 12200, 11800, 12500, 12100, 12800, 13200, 13000, 12700, 13400, 12900, 13500, 13800, 13200, 13600, 14000, 13500, 13800, 14200, 13800, 14000, 14400, 13900, 14100, 14500, 14000, 14200, 14600, 14200, 12847], + // 每小时对话分布(24小时) + hourlyData: [120,80,60,40,35,42,150,380,520,610,580,640,700,620,590,610,720,850,900,880,750,600,420,250], + // 24小时每小时平均活跃用户折线图数据 + hourlyActiveUsers: [4200, 3800, 3500, 3200, 2900, 2800, 3200, 4100, 5600, 6800, 7200, 7500, 7800, 7200, 6900, 7100, 8200, 9500, 10200, 9800, 8500, 7200, 5800, 4800], + // 设备连接趋势数据(按天) + deviceTrend7: [8200, 8350, 8100, 8480, 8620, 8750, 8921], + deviceTrend14: [7800, 7950, 8100, 8020, 8150, 8280, 8350, 8420, 8560, 8680, 8750, 8820, 8880, 8921], + deviceTrend30: [7200, 7350, 7280, 7450, 7520, 7680, 7750, 7820, 7950, 8020, 8150, 8220, 8350, 8420, 8480, 8550, 8620, 8750, 8820, 8880, 8921, 8950, 8980, 8921, 8950, 8980, 8921, 8921, 8921, 8921], + // 设备详情数据 + deviceDetails: [ + { deviceId: 'DEV-202401-001234', lastOnline: '2026-05-13 14:32:15', conversationCount: 156 }, + { deviceId: 'DEV-202401-005678', lastOnline: '2026-05-13 14:28:42', conversationCount: 89 }, + { deviceId: 'DEV-202402-003456', lastOnline: '2026-05-13 14:15:30', conversationCount: 234 }, + { deviceId: 'DEV-202402-007890', lastOnline: '2026-05-13 13:58:21', conversationCount: 67 }, + { deviceId: 'DEV-202403-001234', lastOnline: '2026-05-13 13:42:08', conversationCount: 178 }, + { deviceId: 'DEV-202403-005678', lastOnline: '2026-05-13 12:35:19', conversationCount: 45 }, + { deviceId: 'DEV-202404-003456', lastOnline: '2026-05-13 11:20:55', conversationCount: 312 }, + { deviceId: 'DEV-202404-007890', lastOnline: '2026-05-13 10:15:33', conversationCount: 98 }, + ], + // 对话轮次分布 + turnsData: [8.2, 15.4, 19.1, 28.6, 18.3, 10.4], + turnsLabels: ['1-2', '3-4', '5-6', '7-9', '10-14', '15+'], + // OTA状态 + otaData: [76.3, 8.2, 12.1, 3.4], + // 功能使用分布 + intentData: [24.1, 18.3, 15.7, 9.2, 7.8, 6.5, 5.9, 4.8, 4.1, 3.6], + intentLabels: ['讲故事', '唱儿歌', '问答学习', '天气查询', '闹钟设置', '游戏互动', '英语学习', '百科问答', '情感陪伴', '其他'], + // 语言分布 + langData: [68.4, 12.3, 11.2, 5.1, 3.0], + langLabels: ['中文(普通话)', '中文(粤语)', '英文', '日文', '其他'], + // 热门关键词词云 + wordCloud: [ + { text: '白雪公主', size: 42, color: '#722ed1' }, + { text: '小猪佩奇', size: 38, color: '#1890ff' }, + { text: '儿歌', size: 35, color: '#52c41a' }, + { text: '天气预报', size: 30, color: '#fa8c16' }, + { text: '三只小猪', size: 28, color: '#eb2f96' }, + { text: '英语学习', size: 26, color: '#13c2c2' }, + { text: '睡前故事', size: 32, color: '#722ed1' }, + { text: '十万个为什么', size: 24, color: '#52c41a' }, + { text: '脑筋急转弯', size: 22, color: '#1890ff' }, + { text: '唐诗', size: 20, color: '#fa8c16' }, + { text: '公主城堡', size: 26, color: '#eb2f96' }, + { text: '动物世界', size: 24, color: '#13c2c2' }, + { text: '唱首歌', size: 18, color: '#52c41a' }, + { text: '做游戏', size: 16, color: '#1890ff' }, + ], + }, + 'company_a': { + name: '智慧星科技', + kpi: { + dau: 5200, + dauChange: 12.5, + sessions: 15800, + sessionsChange: 8.2, + devices: 3200, + devicesChange: 85, + totalSessions: 198500, + totalChange: 4.5, + avgTurns: 8.2, + turnsChange: 0.5, + badcaseRate: 0.8, + badcaseChange: -0.2, + }, + dauData: [4800, 4900, 5000, 4950, 5100, 5050, 5200, 5150, 5300, 5250, 5400, 5350, 5450, 5380, 5500, 5420, 5550, 5480, 5600, 5520, 5650, 5580, 5700, 5620, 5750, 5680, 5800, 5720, 5850, 5200], + hourlyData: [80,50,40,30,25,35,120,300,450,520,510,560,600,550,520,540,620,720,780,750,640,500,350,200], + hourlyActiveUsers: [1800, 1650, 1500, 1400, 1250, 1200, 1400, 1800, 2400, 2900, 3100, 3200, 3300, 3050, 2950, 3050, 3500, 4000, 4300, 4100, 3600, 3100, 2500, 2100], + deviceTrend7: [2800, 2900, 2850, 2980, 3050, 3120, 3200], + deviceTrend14: [2600, 2700, 2800, 2750, 2850, 2920, 2980, 3020, 3080, 3120, 3150, 3180, 3190, 3200], + deviceTrend30: [2400, 2500, 2480, 2550, 2600, 2680, 2750, 2800, 2880, 2920, 2980, 3020, 3080, 3120, 3150, 3180, 3190, 3200, 3180, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200], + deviceDetails: [ + { deviceId: 'DEV-A-2024-001234', lastOnline: '2026-05-13 14:30:25', conversationCount: 203 }, + { deviceId: 'DEV-A-2024-005678', lastOnline: '2026-05-13 14:22:18', conversationCount: 156 }, + { deviceId: 'DEV-A-2024-003456', lastOnline: '2026-05-13 14:05:42', conversationCount: 287 }, + { deviceId: 'DEV-A-2024-007890', lastOnline: '2026-05-13 13:45:33', conversationCount: 98 }, + { deviceId: 'DEV-A-2025-001234', lastOnline: '2026-05-13 13:28:11', conversationCount: 175 }, + { deviceId: 'DEV-A-2025-005678', lastOnline: '2026-05-13 12:15:47', conversationCount: 67 }, + { deviceId: 'DEV-A-2025-003456', lastOnline: '2026-05-13 11:08:22', conversationCount: 342 }, + { deviceId: 'DEV-A-2025-007890', lastOnline: '2026-05-13 10:02:55', conversationCount: 124 }, + ], + turnsData: [6.5, 12.8, 18.2, 30.1, 20.4, 12.0], + turnsLabels: ['1-2', '3-4', '5-6', '7-9', '10-14', '15+'], + otaData: [82.5, 6.1, 9.2, 2.2], + intentData: [28.5, 20.1, 18.2, 6.5, 5.2, 4.8, 8.5, 4.2, 2.5, 1.5], + intentLabels: ['讲故事', '唱儿歌', '问答学习', '天气查询', '闹钟设置', '游戏互动', '英语学习', '百科问答', '情感陪伴', '其他'], + langData: [72.1, 8.5, 14.2, 3.2, 2.0], + langLabels: ['中文(普通话)', '中文(粤语)', '英文', '日文', '其他'], + // 热门关键词词云 + wordCloud: [ + { text: '钢铁侠', size: 40, color: '#c41a1a' }, + { text: '蜘蛛侠', size: 36, color: '#1890ff' }, + { text: '迪士尼', size: 32, color: '#722ed1' }, + { text: '超级英雄', size: 30, color: '#eb2f96' }, + { text: '星球大战', size: 28, color: '#faad14' }, + { text: '英语对话', size: 26, color: '#52c41a' }, + { text: '公主故事', size: 34, color: '#eb2f96' }, + { text: '科幻故事', size: 24, color: '#13c2c2' }, + { text: '动画片', size: 22, color: '#1890ff' }, + { text: '玩具总动员', size: 26, color: '#fa8c16' }, + { text: '知识问答', size: 20, color: '#52c41a' }, + { text: '唱跳', size: 18, color: '#722ed1' }, + ], + }, + 'company_b': { + name: '未来玩具厂', + kpi: { + dau: 4100, + dauChange: 5.2, + sessions: 12500, + sessionsChange: 3.8, + devices: 1500, + devicesChange: 45, + totalSessions: 156800, + totalChange: 1.8, + avgTurns: 6.8, + turnsChange: 0.2, + badcaseRate: 1.5, + badcaseChange: -0.4, + }, + dauData: [3800, 3850, 3900, 3880, 3950, 3920, 4000, 3980, 4050, 4020, 4100, 4080, 4150, 4120, 4200, 4180, 4250, 4220, 4300, 4280, 4350, 4320, 4400, 4380, 4450, 4420, 4500, 4480, 4550, 4100], + hourlyData: [60,40,30,25,20,28,90,220,380,450,440,480,520,480,460,470,540,650,700,680,560,420,300,150], + hourlyActiveUsers: [1350, 1200, 1100, 1000, 900, 950, 1100, 1400, 1900, 2300, 2500, 2600, 2700, 2500, 2400, 2500, 2800, 3200, 3500, 3300, 2900, 2500, 1900, 1550], + deviceTrend7: [1320, 1380, 1350, 1400, 1440, 1470, 1500], + deviceTrend14: [1250, 1300, 1320, 1350, 1380, 1400, 1420, 1440, 1460, 1470, 1480, 1490, 1495, 1500], + deviceTrend30: [1150, 1200, 1180, 1220, 1250, 1280, 1300, 1320, 1350, 1380, 1400, 1420, 1440, 1450, 1460, 1470, 1480, 1490, 1495, 1498, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500], + deviceDetails: [ + { deviceId: 'DEV-B-2024-001234', lastOnline: '2026-05-13 14:25:33', conversationCount: 145 }, + { deviceId: 'DEV-B-2024-005678', lastOnline: '2026-05-13 14:12:55', conversationCount: 78 }, + { deviceId: 'DEV-B-2024-003456', lastOnline: '2026-05-13 13:55:18', conversationCount: 198 }, + { deviceId: 'DEV-B-2024-007890', lastOnline: '2026-05-13 13:35:42', conversationCount: 56 }, + { deviceId: 'DEV-B-2025-001234', lastOnline: '2026-05-13 13:18:25', conversationCount: 167 }, + { deviceId: 'DEV-B-2025-005678', lastOnline: '2026-05-13 12:05:33', conversationCount: 43 }, + { deviceId: 'DEV-B-2025-003456', lastOnline: '2026-05-13 11:42:17', conversationCount: 234 }, + { deviceId: 'DEV-B-2025-007890', lastOnline: '2026-05-13 10:28:44', conversationCount: 89 }, + ], + turnsData: [10.2, 18.4, 20.1, 26.2, 16.1, 9.0], + turnsLabels: ['1-2', '3-4', '5-6', '7-9', '10-14', '15+'], + otaData: [74.8, 9.5, 11.8, 3.9], + intentData: [22.1, 22.5, 12.3, 10.8, 8.5, 7.2, 4.5, 5.8, 4.2, 2.1], + intentLabels: ['讲故事', '唱儿歌', '问答学习', '天气查询', '闹钟设置', '游戏互动', '英语学习', '百科问答', '情感陪伴', '其他'], + langData: [65.2, 18.5, 8.5, 5.8, 2.0], + langLabels: ['中文(普通话)', '中文(粤语)', '英文', '日文', '其他'], + // 热门关键词词云 + wordCloud: [ + { text: '小猪佩奇', size: 44, color: '#eb2f96' }, + { text: '汪汪队', size: 38, color: '#1890ff' }, + { text: '儿歌', size: 35, color: '#52c41a' }, + { text: '巧虎', size: 30, color: '#fa8c16' }, + { text: '超级飞侠', size: 28, color: '#722ed1' }, + { text: '睡前故事', size: 26, color: '#13c2c2' }, + { text: '海底小纵队', size: 32, color: '#1890ff' }, + { text: '成语故事', size: 24, color: '#c41a1a' }, + { text: '童谣', size: 22, color: '#52c41a' }, + { text: '认知游戏', size: 20, color: '#fa8c16' }, + { text: '认识动物', size: 26, color: '#722ed1' }, + { text: '认颜色', size: 18, color: '#eb2f96' }, + ], + }, + 'company_c': { + name: '童趣电子', + kpi: { + dau: 3547, + dauChange: 3.1, + sessions: 9904, + sessionsChange: 2.5, + devices: 800, + devicesChange: 25, + totalSessions: 129903, + totalChange: 0.8, + avgTurns: 6.2, + turnsChange: 0.1, + badcaseRate: 2.1, + badcaseChange: -0.5, + }, + dauData: [3200, 3250, 3300, 3280, 3350, 3320, 3400, 3380, 3450, 3420, 3500, 3480, 3550, 3520, 3600, 3580, 3650, 3620, 3700, 3680, 3750, 3720, 3800, 3780, 3850, 3820, 3900, 3880, 3950, 3547], + hourlyData: [50,35,25,20,18,22,70,180,280,350,340,380,420,390,370,380,450,550,600,580,470,320,220,120], + hourlyActiveUsers: [1050, 950, 850, 800, 750, 720, 850, 1050, 1350, 1600, 1700, 1800, 1850, 1700, 1650, 1700, 1950, 2300, 2500, 2400, 2100, 1800, 1400, 1150], + deviceTrend7: [710, 730, 720, 750, 770, 785, 800], + deviceTrend14: [680, 700, 710, 720, 735, 745, 755, 765, 775, 780, 785, 790, 795, 800], + deviceTrend30: [620, 640, 635, 650, 660, 675, 685, 695, 705, 715, 725, 735, 745, 755, 765, 775, 780, 785, 790, 795, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800], + deviceDetails: [ + { deviceId: 'DEV-C-2024-001234', lastOnline: '2026-05-13 14:18:45', conversationCount: 112 }, + { deviceId: 'DEV-C-2024-005678', lastOnline: '2026-05-13 14:05:22', conversationCount: 67 }, + { deviceId: 'DEV-C-2024-003456', lastOnline: '2026-05-13 13:48:35', conversationCount: 156 }, + { deviceId: 'DEV-C-2024-007890', lastOnline: '2026-05-13 13:22:18', conversationCount: 45 }, + { deviceId: 'DEV-C-2025-001234', lastOnline: '2026-05-13 13:05:42', conversationCount: 98 }, + { deviceId: 'DEV-C-2025-005678', lastOnline: '2026-05-13 12:28:55', conversationCount: 38 }, + { deviceId: 'DEV-C-2025-003456', lastOnline: '2026-05-13 11:35:28', conversationCount: 187 }, + { deviceId: 'DEV-C-2025-007890', lastOnline: '2026-05-13 10:42:15', conversationCount: 72 }, + ], + turnsData: [12.5, 20.2, 22.1, 23.5, 14.2, 7.5], + turnsLabels: ['1-2', '3-4', '5-6', '7-9', '10-14', '15+'], + otaData: [68.5, 10.2, 15.8, 5.5], + intentData: [18.2, 15.5, 20.5, 12.5, 10.2, 8.8, 3.2, 5.5, 3.8, 1.8], + intentLabels: ['讲故事', '唱儿歌', '问答学习', '天气查询', '闹钟设置', '游戏互动', '英语学习', '百科问答', '情感陪伴', '其他'], + langData: [58.5, 22.5, 10.5, 6.5, 2.0], + langLabels: ['中文(普通话)', '中文(粤语)', '英文', '日文', '其他'], + // 热门关键词词云 + wordCloud: [ + { text: '熊出没', size: 42, color: '#52c41a' }, + { text: '大头儿子', size: 36, color: '#1890ff' }, + { text: '儿歌', size: 34, color: '#fa8c16' }, + { text: '睡前故事', size: 30, color: '#722ed1' }, + { text: '认识数字', size: 28, color: '#eb2f96' }, + { text: '学英语', size: 26, color: '#13c2c2' }, + { text: '西游记', size: 32, color: '#fa8c16' }, + { text: '神话故事', size: 24, color: '#c41a1a' }, + { text: '儿童谜语', size: 22, color: '#1890ff' }, + { text: '自然科普', size: 20, color: '#52c41a' }, + { text: '安全教育', size: 26, color: '#722ed1' }, + { text: '音乐互动', size: 18, color: '#eb2f96' }, + ], + }, +}; + +// 保存图表实例 +let _dashboardCharts = {}; +let dashboardSelectedPeriod = 30; +let dashboardRefreshSeed = 0; + +// 颜色配置 +const chartColors = { + company_a: { primary: '#722ed1', primaryLight: 'rgba(114,46,209,0.12)' }, + company_b: { primary: '#52c41a', primaryLight: 'rgba(82,196,26,0.12)' }, + company_c: { primary: '#fa8c16', primaryLight: 'rgba(250,140,22,0.12)' }, + all: { primary: '#1890ff', primaryLight: 'rgba(24,144,255,0.12)' }, +}; + +function getDashboardCompanyId() { + if (window.__getCurrentCompanyId) return window.__getCurrentCompanyId(); + return typeof window.currentCompany === 'string' ? window.currentCompany : 'all'; +} + +registerPage('dashboard', function(container) { + container.innerHTML = ` + + + +
+
+
+
今日活跃设备
+
-
+
-
+
+
+
+
今日对话数
+
-
+
-
+
+
+
+
总设备数
+
-
+
+
+
+
总对话数
+
-
+
-
+
+
+
+
平均每次对话互动
+
-
+
-
+
+
+
+
问题对话率
+
-
+
-
+
+
+ + +
+
+
+
+
对话记录统计
+
近 30 天每日对话次数趋势
+
+
+ +
+
+ +
+
+
+
+
设备连接趋势
+
近 30 天按日期统计设备连接数量
+
+
+ +
+
+ +
+
+
+
热门关键词词云
+
+ +
+
+
+
+
热门话题排行
+
点击柱状图查看相关会话
+
+
+ +
+
+ + +
+
+
+
设备连接详情
+
+
+
+
+ `; + + lucide.createIcons({ elements: [container] }); + + // 初始化图表 + initDashboardCharts(); + syncDashboardPeriodControls(dashboardSelectedPeriod); + bindDashboardDateRange(); + + // 更新当前公司描述 + updateDashboardCompanyDesc(); + + // 绑定公司切换事件 + window.removeEventListener('companyChanged', handleCompanyChange); + window.addEventListener('companyChanged', handleCompanyChange); + window.removeEventListener('devicesChanged', handleDeviceDataChange); + window.addEventListener('devicesChanged', handleDeviceDataChange); +}); + +// 处理公司切换 +function handleCompanyChange() { + updateDashboardCompanyDesc(); + updateDashboardData(); +} + +function handleDeviceDataChange() { + updateDashboardCompanyDesc(); + updateDashboardData(); +} + +function updateDashboardCompanyDesc() { + const desc = document.getElementById('dashboardCompanyDesc'); + const companyId = getDashboardCompanyId(); + const company = companyId === 'all' + ? { id: 'all', name: '全部公司' } + : (window.__getCompanyById + ? window.__getCompanyById(companyId) + : window.companies?.find(c => c.id === companyId)); + const appScope = window.__dashboardAppScope; + if (desc && company) { + desc.textContent = appScope?.companyId === companyId + ? `${company.name} · ${appScope.name} · 智能体数据分析` + : `${company.name} · 实时监控 AI 玩具运营情况`; + } +} + +function getDashboardStats(companyId) { + const base = companyStats[companyId] || createEmptyDashboardStats(companyId); + const liveStats = applyLiveDeviceStats(base, companyId); + if (!dashboardRefreshSeed) return liveStats; + return applyDashboardRefreshSnapshot(liveStats, dashboardRefreshSeed); +} + +function createEmptyDashboardStats(companyId) { + const company = window.__getCompanyById ? window.__getCompanyById(companyId) : null; + return { + name: company?.name || '新公司', + kpi: { + dau: 0, + dauChange: 0, + sessions: 0, + sessionsChange: 0, + devices: 0, + devicesChange: 0, + totalSessions: 0, + totalChange: 0, + avgTurns: 0, + turnsChange: 0, + badcaseRate: 0, + badcaseChange: 0, + }, + dauData: Array.from({ length: 30 }, () => 0), + hourlyActiveUsers: Array.from({ length: 24 }, () => 0), + deviceTrend7: Array.from({ length: 7 }, () => 0), + deviceTrend14: Array.from({ length: 14 }, () => 0), + deviceTrend30: Array.from({ length: 30 }, () => 0), + deviceDetails: [], + wordCloud: [], + intentLabels: [], + intentData: [], + }; +} + +function getLiveDashboardDevices(companyId) { + if (typeof window.__getAllDevices !== 'function') return null; + const devices = window.__getAllDevices(); + if (companyId === 'all') return devices; + return devices.filter(device => device.companyId === companyId); +} + +function applyLiveDeviceStats(base, companyId) { + const liveDevices = getLiveDashboardDevices(companyId); + if (!liveDevices) return base; + + const totalDevices = liveDevices.length; + const onlineDevices = liveDevices.filter(device => device.status === 'online').length; + const deviceDetails = liveDevices.slice(0, 8).map(device => ({ + deviceId: device.id, + lastOnline: device.lastSeen || '-', + conversationCount: Math.max(0, Math.round((device.battery || 0) / 8) + (device.status === 'online' ? 8 : 1)), + })); + + const withLiveDeviceTrend = (series = []) => { + const targetLength = series.length || 7; + if (totalDevices === 0) return Array.from({ length: targetLength }, () => 0); + return Array.from({ length: targetLength }, (_, index) => { + const progress = targetLength <= 1 ? 1 : index / (targetLength - 1); + const baseline = Math.max(0, Math.round(totalDevices * (0.82 + progress * 0.18))); + return index === targetLength - 1 ? totalDevices : baseline; + }); + }; + + const isNewCompany = !companyStats[companyId]; + return { + ...base, + kpi: { + ...base.kpi, + devices: totalDevices, + dau: isNewCompany ? onlineDevices : base.kpi.dau, + sessions: isNewCompany ? onlineDevices * 3 : base.kpi.sessions, + totalSessions: isNewCompany ? onlineDevices * 18 : base.kpi.totalSessions, + avgTurns: isNewCompany ? (onlineDevices ? 5.2 : 0) : base.kpi.avgTurns, + }, + deviceTrend7: withLiveDeviceTrend(base.deviceTrend7), + deviceTrend14: withLiveDeviceTrend(base.deviceTrend14), + deviceTrend30: withLiveDeviceTrend(base.deviceTrend30), + deviceDetails: deviceDetails.length ? deviceDetails : [], + }; +} + +function applyDashboardRefreshSnapshot(base, seed) { + const wave = Math.sin(seed * 1.37); + const smallWave = Math.cos(seed * 0.91); + const deviceDelta = Math.max(1, Math.round(18 + seed * 3 + wave * 10)); + const dauDelta = Math.round(120 + wave * 70); + const sessionDelta = Math.round(420 + smallWave * 180); + const totalDelta = Math.round(900 + seed * 140 + wave * 260); + const avgTurnsDelta = Number((smallWave * 0.18).toFixed(1)); + const badcaseDelta = Number((Math.sin(seed * 0.63) * 0.18).toFixed(1)); + + return { + ...base, + kpi: { + ...base.kpi, + dau: Math.max(0, base.kpi.dau + dauDelta), + dauChange: Number((base.kpi.dauChange + wave * 0.6).toFixed(1)), + sessions: Math.max(0, base.kpi.sessions + sessionDelta), + sessionsChange: Number((base.kpi.sessionsChange + smallWave * 0.5).toFixed(1)), + devices: Math.max(0, base.kpi.devices + deviceDelta), + devicesChange: deviceDelta, + totalSessions: Math.max(0, base.kpi.totalSessions + totalDelta), + totalChange: Number((base.kpi.totalChange + wave * 0.3).toFixed(1)), + avgTurns: Math.max(1, Number((base.kpi.avgTurns + avgTurnsDelta).toFixed(1))), + turnsChange: Number((base.kpi.turnsChange + avgTurnsDelta).toFixed(1)), + badcaseRate: Math.max(0, Number((base.kpi.badcaseRate + badcaseDelta).toFixed(1))), + badcaseChange: Number((base.kpi.badcaseChange - badcaseDelta).toFixed(1)), + }, + dauData: refreshSeries(base.dauData, seed, 160), + hourlyActiveUsers: refreshSeries(base.hourlyActiveUsers, seed, 90), + deviceTrend7: refreshSeries(base.deviceTrend7, seed, 36), + deviceTrend14: refreshSeries(base.deviceTrend14, seed, 32), + deviceTrend30: refreshSeries(base.deviceTrend30, seed, 28), + intentData: refreshPercentSeries(base.intentData, seed, 1.1), + langData: normalizePercentSeries(refreshPercentSeries(base.langData, seed, 0.5)), + wordCloud: (base.wordCloud || []).map((word, index) => ({ + ...word, + size: Math.max(16, Math.round(word.size + Math.sin((index + 1) * seed * 0.37) * 3)), + })), + deviceDetails: (base.deviceDetails || []).map((item, index) => ({ + ...item, + lastOnline: index === 0 ? '刚刚' : item.lastOnline, + conversationCount: Math.max(0, item.conversationCount + Math.round(Math.sin((index + 1) * seed) * 8 + seed)), + })), + }; +} + +function refreshSeries(series = [], seed, amplitude) { + return series.map((value, index) => { + const delta = Math.round(Math.sin((index + 1) * 0.62 + seed) * amplitude + seed * 3); + return Math.max(0, value + delta); + }); +} + +function refreshPercentSeries(series = [], seed, amplitude) { + return series.map((value, index) => { + const delta = Math.sin((index + 1) * 0.78 + seed) * amplitude; + return Number(Math.max(0.1, value + delta).toFixed(1)); + }); +} + +function normalizePercentSeries(series = []) { + const total = series.reduce((sum, value) => sum + value, 0); + if (!total) return series; + return series.map(value => Number((value * 100 / total).toFixed(1))); +} + +// 更新 KPI 数据 +function updateDashboardData() { + const companyId = getDashboardCompanyId(); + const stats = getDashboardStats(companyId); + + // 更新 KPI 数值 + document.getElementById('kpi-dau').textContent = `${formatNumber(stats.kpi.dau)} 台`; + document.getElementById('kpi-sessions').textContent = `${formatNumber(stats.kpi.sessions)} 次`; + document.getElementById('kpi-devices').textContent = `${formatNumber(stats.kpi.devices)} 台`; + document.getElementById('kpi-activation').textContent = `${formatNumber(stats.kpi.totalSessions)} 次`; + document.getElementById('kpi-turns').textContent = `${stats.kpi.avgTurns.toFixed(1)} 轮`; + document.getElementById('kpi-badcase').textContent = stats.kpi.badcaseRate.toFixed(1) + '%'; + + // 更新 KPI 变化 + updateKpiChange('kpi-dau', stats.kpi.dauChange, { unit: '%', suffix: '较昨日' }); + updateKpiChange('kpi-sessions', stats.kpi.sessionsChange, { unit: '%', suffix: '较昨日' }); + updateKpiChange('kpi-activation', stats.kpi.totalChange, { unit: '%', suffix: '较昨日' }); + updateKpiChange('kpi-turns', stats.kpi.turnsChange, { unit: '轮', suffix: '较昨日' }); + updateKpiChange('kpi-badcase', stats.kpi.badcaseChange, { unit: '%', suffix: '较昨日', inverse: true }); + + // 更新简洁统计卡片 + const statDau = document.getElementById('stat-dau'); + const statDevices = document.getElementById('stat-devices'); + const statSessions = document.getElementById('stat-sessions'); + const statTotal = document.getElementById('stat-total'); + if (statDau) statDau.textContent = `${stats.kpi.dau.toLocaleString()} 台`; + if (statDevices) statDevices.textContent = `${stats.kpi.devices.toLocaleString()} 台`; + if (statSessions) statSessions.textContent = `${stats.kpi.sessions.toLocaleString()} 次`; + if (statTotal) statTotal.textContent = `${stats.kpi.totalSessions.toLocaleString()} 次`; + + // 更新图表 + updateDashboardCharts(companyId); +} + +function updateKpiChange(id, change, options = {}) { + const el = document.getElementById(id + '-change'); + if (!el) return; + + const { unit = '%', absolute = false, inverse = false, suffix = '' } = options; + const isUp = inverse ? change < 0 : change > 0; + const isDown = inverse ? change > 0 : change < 0; + const sign = change > 0 ? '+' : ''; + const value = absolute ? `${sign}${change}` : `${sign}${change.toFixed(1)}`; + const unitText = unit ? ` ${unit}` : ''; + const suffixText = suffix ? ` ${suffix}` : ''; + + el.className = 'kpi-change ' + (isUp ? 'up' : isDown ? 'down' : ''); + el.innerHTML = ` + + ${value}${unitText}${suffixText}${isDown ? ' 持续改善中' : ''} + `; + lucide.createIcons({ nodes: [el] }); +} + +function formatNumber(num) { + if (num >= 10000) { + return (num / 10000).toFixed(1) + '万'; + } + return num.toLocaleString(); +} + +// 渲染词云 +function renderWordCloud(containerId, words) { + const container = document.getElementById(containerId); + if (!container || !words) return; + + // 创建词云容器 + let cloudContainer = container.parentElement.querySelector('.wordcloud-container'); + if (!cloudContainer) { + cloudContainer = document.createElement('div'); + cloudContainer.className = 'wordcloud-container'; + cloudContainer.style.cssText = ` + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 12px 18px; + padding: 20px; + min-height: 200px; + align-content: center; + `; + container.style.display = 'none'; + container.parentElement.appendChild(cloudContainer); + } + + // 渲染词云文字 + cloudContainer.innerHTML = words.map(word => ` + + ${word.text} + + `).join(''); +} + +// 更新图表数据 +function updateDashboardCharts(companyId) { + const stats = getDashboardStats(companyId); + const colors = chartColors[companyId] || chartColors['all']; + const period = getCurrentTrendPeriod(); + syncDashboardPeriodControls(period); + + // 更新对话记录趋势图表 + if (_dashboardCharts.hourlyDau) { + _dashboardCharts.hourlyDau.data.datasets[0].borderColor = colors.primary; + _dashboardCharts.hourlyDau.data.datasets[0].backgroundColor = colors.primaryLight; + _dashboardCharts.hourlyDau.data.labels = genLabels(period); + _dashboardCharts.hourlyDau.data.datasets[0].data = getConversationTrendData(stats, period); + _dashboardCharts.hourlyDau.update(); + } + + // 更新设备连接趋势图表(保持当前选中的周期) + updateDeviceTrendChart(companyId, period); + + const keywordCloud = getWordCloudData(stats, period); + const topicRanking = getTopicRankingData(stats, period); + + // 更新热门关键词词云 + renderWordCloud('turnsChart', keywordCloud); + + // 更新热门话题排行图表 + if (_dashboardCharts.intent) { + _dashboardCharts.intent.data.datasets[0].data = topicRanking.map(item => item.score); + _dashboardCharts.intent.data.labels = topicRanking.map(item => item.text); + _dashboardCharts.intent.data.datasets[0].backgroundColor = colors.primaryLight.replace('0.12', '0.7'); + _dashboardCharts.intent.update(); + } + + // 更新设备详情表格 + updateDeviceDetailTable(companyId); +} + +// 获取当前设备连接趋势的周期 +function getCurrentTrendPeriod() { + return dashboardSelectedPeriod; +} + +// 更新设备连接趋势图表 +function updateDeviceTrendChart(companyId, period) { + const stats = getDashboardStats(companyId); + const normalizedPeriod = normalizeDashboardPeriod(period); + const data = getDeviceTrendData(stats, normalizedPeriod); + dashboardSelectedPeriod = normalizedPeriod; + syncDashboardPeriodControls(normalizedPeriod); + const labels = genLabels(normalizedPeriod); + if (_dashboardCharts.deviceTrend) { + _dashboardCharts.deviceTrend.data.labels = labels; + _dashboardCharts.deviceTrend.data.datasets[0].data = data; + _dashboardCharts.deviceTrend.update(); + } +} + +function normalizeDashboardPeriod(period) { + const value = Number.parseInt(period, 10); + return [7, 14, 30].includes(value) ? value : 30; +} + +function getDeviceTrendData(stats, period) { + if (period === 7) return stats.deviceTrend7 || []; + if (period === 14) return stats.deviceTrend14 || stats.deviceTrend7 || []; + return stats.deviceTrend30 || stats.deviceTrend14 || stats.deviceTrend7 || []; +} + +function getDauTrendData(stats, period) { + const source = stats.dauData || []; + if (source.length >= period) return source.slice(-period); + return padSeries(source, period); +} + +function getConversationTrendData(stats, period) { + const activeDeviceSeries = getDauTrendData(stats, period); + const todayActiveDevices = activeDeviceSeries[activeDeviceSeries.length - 1] || stats.kpi.dau || 1; + const todaySessions = stats.kpi.sessions || 0; + const sessionsPerDevice = todayActiveDevices ? todaySessions / todayActiveDevices : 3; + + return activeDeviceSeries.map((value, index) => { + if (index === activeDeviceSeries.length - 1) { + return Math.max(0, Math.round(todaySessions)); + } + + const progress = activeDeviceSeries.length <= 1 ? 1 : index / (activeDeviceSeries.length - 1); + const weekdayWave = 1 + Math.sin((index + 1) * 0.9) * 0.06 + Math.cos((index + 1) * 0.37) * 0.04; + const recentLift = 0.92 + progress * 0.08; + return Math.max(0, Math.round(value * sessionsPerDevice * weekdayWave * recentLift)); + }); +} + +function getWordCloudData(stats, period) { + const factor = getPeriodFactor(period); + return (stats.wordCloud || []).map((word, index) => ({ + ...word, + size: Math.max(16, Math.round(word.size + Math.cos((index + 1) * factor) * (period === 7 ? 4 : period === 14 ? 2 : 0))), + })); +} + +function getTopicRankingData(stats, period) { + const labels = stats.intentLabels || []; + const factor = getPeriodFactor(period); + const values = (stats.intentData || []).map((value, index) => { + const adjusted = value + Math.sin((index + 1) * factor) * (period === 7 ? 1.8 : period === 14 ? 1.1 : 0.4); + return Math.max(0.1, adjusted); + }); + const total = values.reduce((sum, value) => sum + value, 0) || 1; + return labels.map((label, index) => ({ + text: label, + score: Number(((values[index] || 0) * 100 / total).toFixed(1)), + })).sort((a, b) => b.score - a.score); +} + +function openDashboardTopicSessions(topic) { + if (!topic) return; + const company = window.getCurrentCompany + ? window.getCurrentCompany() + : { id: getDashboardCompanyId(), name: companyStats[getDashboardCompanyId()]?.name || '全部公司' }; + const payload = { + topic, + companyId: getDashboardCompanyId(), + companyName: company.name, + period: normalizeDashboardPeriod(dashboardSelectedPeriod), + source: 'dashboard-topic-ranking', + createdAt: Date.now(), + }; + sessionStorage.setItem('talkingq_session_topic_drilldown', JSON.stringify(payload)); + Toast.info(`正在打开「${topic}」相关会话`, 900); + navigateTo('session'); +} + +function getPeriodFactor(period) { + if (period === 7) return 0.9; + if (period === 14) return 0.55; + return 0.2; +} + +function padSeries(source, length) { + if (!source.length) return Array.from({ length }, () => 0); + const result = []; + for (let i = 0; i < length; i++) { + result.push(source[i % source.length]); + } + return result; +} + +function updateDashboardPeriod(period) { + dashboardSelectedPeriod = normalizeDashboardPeriod(period); + syncDashboardPeriodControls(dashboardSelectedPeriod); + updateDashboardData(); +} + +function syncDashboardPeriodControls(period) { + const normalizedPeriod = normalizeDashboardPeriod(period); + const select = document.getElementById('dashDateRange'); + if (select) select.value = String(normalizedPeriod); + + const subtitle = document.getElementById('deviceTrendSubtitle'); + if (subtitle) { + subtitle.textContent = `近 ${normalizedPeriod} 天按日期统计设备连接数量`; + } + const dialogSubtitle = document.getElementById('dialogTrendSubtitle'); + if (dialogSubtitle) { + dialogSubtitle.textContent = `近 ${normalizedPeriod} 天每日对话次数趋势`; + } +} + +function bindDashboardDateRange() { + const select = document.getElementById('dashDateRange'); + if (!select) return; + select.addEventListener('change', () => { + updateDashboardPeriod(select.value); + }); +} + +// 更新设备连接详情表格 +function updateDeviceDetailTable(companyId) { + const stats = getDashboardStats(companyId); + const tableEl = document.getElementById('deviceDetailTable'); + if (!tableEl) return; + + tableEl.innerHTML = ` +
+ + + + + + + + + + ${stats.deviceDetails.length ? stats.deviceDetails.map(d => ` + + + + + + `).join('') : ` + + + + `} + +
设备ID最后在线时间对话次数
${d.deviceId}${d.lastOnline}${d.conversationCount} 次
暂无设备数据
+
`; +} + +// 刷新仪表盘 +function refreshDashboard() { + const refreshBtn = document.querySelector('[data-action="dashboard-refresh"]'); + if (refreshBtn) refreshBtn.disabled = true; + Toast.info('数据刷新中...', 600); + setTimeout(() => { + dashboardRefreshSeed += 1; + updateDashboardData(); + if (refreshBtn) refreshBtn.disabled = false; + Toast.success('数据已更新', 900); + }, 250); +} + +function initDashboardCharts() { + const gridColor = 'rgba(0,0,0,0.05)'; + const currentCompany = getDashboardCompanyId(); + const stats = getDashboardStats(currentCompany); + const colors = chartColors[currentCompany] || chartColors['all']; + if (_dashboardCharts.lang) { + _dashboardCharts.lang.destroy(); + _dashboardCharts.lang = null; + } + + const defaultOptions = { + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { color: gridColor }, ticks: { font: { size: 11 }, color: '#8c8c8c' } }, + y: { grid: { color: gridColor }, ticks: { font: { size: 11 }, color: '#8c8c8c' } }, + }, + }; + + // 对话记录趋势图表 + const hourlyDauCtx = document.getElementById('hourlyDauChart'); + if (hourlyDauCtx) { + const period = normalizeDashboardPeriod(dashboardSelectedPeriod); + _dashboardCharts.hourlyDau = new Chart(hourlyDauCtx, { + type: 'line', + data: { + labels: genLabels(period), + datasets: [{ + label: '对话次数', + data: getConversationTrendData(stats, period), + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + fill: true, + tension: 0.4, + pointRadius: 3, + pointHoverRadius: 6, + borderWidth: 2, + }], + }, + options: { + ...defaultOptions, + plugins: { + legend: { display: false }, + tooltip: { mode: 'index', intersect: false, callbacks: { label: ctx => ` ${Number(ctx.raw).toLocaleString()} 次对话` } }, + }, + scales: { + x: { grid: { color: gridColor }, ticks: { font: { size: 11 }, color: '#8c8c8c' } }, + y: { grid: { color: gridColor }, ticks: { font: { size: 11 }, color: '#8c8c8c', callback: v => v.toLocaleString() } }, + }, + }, + }); + } + + // 设备连接趋势折线图 + const deviceTrendCtx = document.getElementById('deviceTrendChart'); + if (deviceTrendCtx) { + const period = normalizeDashboardPeriod(dashboardSelectedPeriod); + const labels = genLabels(period); + _dashboardCharts.deviceTrend = new Chart(deviceTrendCtx, { + type: 'line', + data: { + labels, + datasets: [{ + label: '设备数', + data: getDeviceTrendData(stats, period), + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + fill: true, + tension: 0.4, + pointRadius: 3, + pointHoverRadius: 6, + borderWidth: 2, + }], + }, + options: { + ...defaultOptions, + plugins: { + legend: { display: false }, + tooltip: { mode: 'index', intersect: false, callbacks: { label: ctx => ` ${ctx.raw} 台设备` } }, + }, + scales: { + x: { grid: { color: gridColor }, ticks: { font: { size: 11 }, color: '#8c8c8c' } }, + y: { grid: { color: gridColor }, ticks: { font: { size: 11 }, color: '#8c8c8c', callback: v => v.toLocaleString() } }, + }, + }, + }); + } + + // Turns distribution - 词云效果 + const turnsCtx = document.getElementById('turnsChart'); + if (turnsCtx) { + // 清空现有图表 + turnsCtx.getContext('2d').clearRect(0, 0, turnsCtx.width, turnsCtx.height); + // 销毁旧图表实例 + if (_dashboardCharts.turns) { + _dashboardCharts.turns.destroy(); + _dashboardCharts.turns = null; + } + } + + const period = normalizeDashboardPeriod(dashboardSelectedPeriod); + const keywordCloud = getWordCloudData(stats, period); + const topicRanking = getTopicRankingData(stats, period); + + // 渲染词云 + renderWordCloud('turnsChart', keywordCloud); + + // Topic Ranking Bar + const intentCtx = document.getElementById('intentChart'); + if (intentCtx) { + _dashboardCharts.intent = new Chart(intentCtx, { + type: 'bar', + data: { + labels: topicRanking.map(item => item.text), + datasets: [{ + label: '占比', + data: topicRanking.map(item => item.score), + backgroundColor: colors.primaryLight.replace('0.12', '0.7'), + borderRadius: 4, + borderSkipped: false, + }], + }, + options: { + ...defaultOptions, + indexAxis: 'y', + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: ctx => ` 占比 ${ctx.raw}%` } }, + }, + onClick: (event, elements, chart) => { + if (!elements.length) return; + const topic = chart.data.labels[elements[0].index]; + openDashboardTopicSessions(topic); + }, + onHover: (event, elements) => { + const target = event?.native?.target; + if (target) target.style.cursor = elements.length ? 'pointer' : 'default'; + }, + scales: { + x: { grid: { color: gridColor }, ticks: { callback: v => v + '%', font: { size: 11 }, color: '#8c8c8c' } }, + y: { grid: { display: false }, ticks: { font: { size: 11 }, color: '#595959' } }, + }, + }, + }); + } + + // 设备连接详情表格 + updateDeviceDetailTable(currentCompany); + + // 初始化时更新 KPI + updateDashboardData(); +} + +function genLabels(n) { + const labels = []; + const today = new Date(); + for (let i = n - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + labels.push(`${d.getMonth()+1}/${d.getDate()}`); + } + return labels; +} diff --git a/scripts/device.js b/scripts/device.js new file mode 100644 index 0000000..10afda8 --- /dev/null +++ b/scripts/device.js @@ -0,0 +1,1255 @@ +/** + * TalkingQ Platform - Device Management Page + * 按公司分类的设备管理 + */ + +const DEVICE_BASE_DEVICES = [ + // 智慧星科技 (company_a) + { id: 'c2b00f75-e7e7-581e-90f1-390d97d0dee6', name: '智慧星 #A001', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_07DF6F77F19C0A55', fw: 'v2.3.1', status: 'online', battery: 85, lastSeen: '刚刚', region: '广东广州' }, + { id: 'f13a62b9-5c2e-5bb9-9f7b-4d9a3a7c2d01', name: '智慧星 #A002', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_0B8A6C21E94F3D10', fw: 'v2.3.0', status: 'online', battery: 62, lastSeen: '1分钟前', region: '广东深圳' }, + { id: '9d7a4c26-8f13-5c2d-a764-7f90c5b118a2', name: '智慧星 #A003', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_14C9F02A77D85B61', fw: 'v2.2.5', status: 'offline', battery: 0, lastSeen: '3天前', region: '广东广州' }, + { id: 'ad0f462a-2166-5148-9af3-64e711c3df78', name: '智慧星 #A004', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_6F91A2C8B0D44E73', fw: 'v2.3.1', status: 'error', battery: 45, lastSeen: '10分钟前', region: '广东佛山' }, + { id: 'e51f9c0b-7d1a-58d4-8c03-1ac9f6b2e945', name: '智慧星 #A005', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_A03C5E79B1D2468F', fw: 'v2.3.1', status: 'online', battery: 33, lastSeen: '5分钟前', region: '广东东莞' }, + + // 未来玩具厂 (company_b) + { id: 'b0d5e7c2-6f3a-5a19-9c87-21b64f9e0a33', name: '未来玩具 #B001', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_3E6A9B12C8D074F5', fw: 'v2.0.2', status: 'online', battery: 90, lastSeen: '刚刚', region: '浙江杭州' }, + { id: '0a91d4e5-2c6f-53b8-8e14-97d23c5f61a0', name: '未来玩具 #B002', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_D4F0219A6B8C35E7', fw: 'v2.0.0', status: 'online', battery: 55, lastSeen: '10分钟前', region: '浙江宁波' }, + { id: '6cc1b8a3-9a2d-5f71-8d42-f0a7b3c9d5e2', name: '未来玩具 #B003', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_82A4F6D13C9E0B75', fw: 'v1.8.2', status: 'offline', battery: 0, lastSeen: '2天前', region: '浙江温州' }, + { id: '4f82a7c9-b1d3-5e06-9a54-3c1d8f7b02a6', name: '未来玩具 #B004', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_59C0E7A31D8B246F', fw: 'v2.0.2', status: 'online', battery: 78, lastSeen: '1分钟前', region: '浙江义乌' }, + + // 童趣电子 (company_c) + { id: '7b31f9e2-08d6-5a44-9b2c-6e1a0f93c58d', name: '童趣电子 #C001', companyId: 'company_c', companyName: '童趣电子', sn: 'TQ_B7D04A6E2C9138F5', fw: 'v1.8.2', status: 'online', battery: 72, lastSeen: '刚刚', region: '江苏苏州' }, + { id: '2c9e6a41-d0b7-53f8-8a15-e6c37b9402df', name: '童趣电子 #C002', companyId: 'company_c', companyName: '童趣电子', sn: 'TQ_1F8D3B60A7C9E452', fw: 'v1.8.2', status: 'offline', battery: 0, lastSeen: '5天前', region: '江苏南京' }, + { id: 'd6a0c7e8-39f4-50b2-8d91-4a6e27c5f0b3', name: '童趣电子 #C003', companyId: 'company_c', companyName: '童趣电子', sn: 'TQ_E2B8C14F09A673D5', fw: 'v1.8.2', status: 'error', battery: 15, lastSeen: '1小时前', region: '江苏无锡' }, +]; + +try { + [ + 'talkingq_added_devices_v1', + 'talkingq_removed_device_ids_v1', + 'talkingq_device_company_overrides_v1', + ].forEach(key => localStorage.removeItem(key)); +} catch (error) { + console.warn('Failed to clear legacy device cache', error); +} + +function saveStoredDevices() { + // Device changes are intentionally kept in memory for the demo. +} + +const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SERIAL_NUMBER_PATTERN = /^TQ_[0-9A-F]{16}$/i; + +function normalizeSerialNumber(value) { + return String(value || '').trim().toUpperCase(); +} + +function normalizeDeviceRecord(device) { + const deviceId = String(device.device_id || device.id || '').trim(); + const serialNumber = normalizeSerialNumber(device.serial_number || device.sn); + return { + ...device, + id: deviceId, + device_id: deviceId, + sn: serialNumber, + serial_number: serialNumber, + }; +} + +function generateDeviceId(companyId, allDevices) { + const usedIds = new Set((allDevices || []).map(device => String(device.id || device.device_id || '').toLowerCase())); + if (window.crypto?.randomUUID) { + let nextId = window.crypto.randomUUID(); + while (usedIds.has(nextId.toLowerCase())) { + nextId = window.crypto.randomUUID(); + } + return nextId; + } + + let nextId = ''; + do { + nextId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => { + const value = Math.floor(Math.random() * 16); + return (char === 'x' ? value : (value & 0x3) | 0x8).toString(16); + }); + } while (usedIds.has(nextId.toLowerCase())); + return nextId; +} + +function generateSerialNumber(allDevices) { + const usedSerials = new Set((allDevices || []).map(device => normalizeSerialNumber(device.sn || device.serial_number))); + let serialNumber = ''; + do { + const hex = Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('').toUpperCase(); + serialNumber = `TQ_${hex}`; + } while (usedSerials.has(serialNumber)); + return serialNumber; +} + +window.__deviceBaseDevices = window.__deviceBaseDevices || DEVICE_BASE_DEVICES.map(device => normalizeDeviceRecord(device)); +window.__deviceAddedDevices = window.__deviceAddedDevices || []; +window.__deviceRemovedIds = window.__deviceRemovedIds || []; +window.__deviceCompanyOverrides = window.__deviceCompanyOverrides || {}; +window.__saveDeviceState = window.__saveDeviceState || saveStoredDevices; + +function applyDeviceCompanyOverride(device) { + const override = window.__deviceCompanyOverrides?.[device.id]; + return normalizeDeviceRecord(override ? { ...device, ...override } : device); +} + +function getEffectiveDeviceCompanyId(device) { + return window.__deviceCompanyOverrides?.[device.id]?.companyId || device.companyId; +} + +window.__getAllDevices = window.__getAllDevices || function() { + const removedSet = new Set(window.__deviceRemovedIds || []); + return (window.__deviceBaseDevices || []) + .concat(window.__deviceAddedDevices || []) + .filter(device => !removedSet.has(device.id)) + .map(device => applyDeviceCompanyOverride(device)); +}; + +window.__reassignCompanyDevices = window.__reassignCompanyDevices || function(sourceCompanyId, targetCompanyId) { + if (!sourceCompanyId || !targetCompanyId || sourceCompanyId === targetCompanyId) return 0; + const targetCompanyName = window.__getCompanyNameById ? window.__getCompanyNameById(targetCompanyId) : targetCompanyId; + let movedCount = 0; + const addedIds = new Set((window.__deviceAddedDevices || []).map(device => device.id)); + const updateList = list => { + list.forEach(device => { + if (getEffectiveDeviceCompanyId(device) === sourceCompanyId) { + if (addedIds.has(device.id)) { + delete window.__deviceCompanyOverrides[device.id]; + } else { + window.__deviceCompanyOverrides[device.id] = { + companyId: targetCompanyId, + companyName: targetCompanyName, + }; + } + device.companyId = targetCompanyId; + device.companyName = targetCompanyName; + movedCount += 1; + } + }); + }; + updateList(window.__deviceBaseDevices || []); + updateList(window.__deviceAddedDevices || []); + window.__saveDeviceState?.(); + return movedCount; +}; + +window.__resetDeviceMockData = window.__resetDeviceMockData || function() { + window.__deviceBaseDevices = DEVICE_BASE_DEVICES.map(device => normalizeDeviceRecord(device)); + window.__deviceAddedDevices = []; + window.__deviceRemovedIds = []; + window.__deviceCompanyOverrides = {}; + window.__saveDeviceState?.(); + window.dispatchEvent(new CustomEvent('devicesChanged', { + detail: { companyId: 'all', reset: true }, + })); +}; + +registerPage('device', function(container) { + // 获取当前公司 + const currentCompanyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : (window.currentCompany || 'all'); + const currentCompany = window.getCurrentCompany ? window.getCurrentCompany() : { id: 'all', name: '全部公司' }; + const currentRole = window.__getUserRole ? window.__getUserRole() : 'admin'; + const isClient = currentRole === 'client'; + const baseDevices = window.__deviceBaseDevices || []; + window.__deviceAddedDevices = window.__deviceAddedDevices || []; + window.__deviceRemovedIds = window.__deviceRemovedIds || []; + window.__deviceCompanyOverrides = window.__deviceCompanyOverrides || {}; + + function getCompanyInfo(companyId) { + const company = window.__getCompanyById ? window.__getCompanyById(companyId) : null; + return company || { id: companyId, name: companyId, latestVersion: 'v1.0.0' }; + } + + function getCompanyName(companyId) { + return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : getCompanyInfo(companyId).name; + } + + function getLatestFirmware(companyId) { + return getCompanyInfo(companyId).latestVersion || 'v1.0.0'; + } + + function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); + } + + function escapeInlineValue(value) { + return String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r?\n/g, ' '); + } + + function adjustCompanyDeviceCount(companyId, delta) { + const company = window.companies?.find(item => item.id === companyId); + if (!company) return; + company.deviceCount = Math.max(0, (company.deviceCount || 0) + delta); + } + + function getStatusLabel(status) { + return { online: '在线', offline: '离线', error: '异常' }[status] || '未知'; + } + + function getLiveDevices() { + const removedSet = new Set(window.__deviceRemovedIds || []); + return (window.__deviceBaseDevices || []) + .concat(window.__deviceAddedDevices || []) + .filter(device => !removedSet.has(device.id)) + .map(device => applyDeviceCompanyOverride(device)); + } + + function getDefaultDeviceCompanyId(selectableCompanies) { + if (currentCompanyId !== 'all') return currentCompanyId; + if (window.currentCompany && window.currentCompany !== 'all') return window.currentCompany; + return selectableCompanies[0]?.id || 'company_a'; + } + + function buildDeviceCompanyField(fieldId, selectedCompanyId) { + const fallbackCompanies = [ + { id: 'company_a', name: '智慧星科技' }, + { id: 'company_b', name: '未来玩具厂' }, + { id: 'company_c', name: '童趣电子' }, + ]; + const selectableCompanies = (window.companies?.length ? window.companies : fallbackCompanies) + .filter(company => company.id !== 'all'); + const safeSelectedId = selectableCompanies.some(company => company.id === selectedCompanyId) + ? selectedCompanyId + : getDefaultDeviceCompanyId(selectableCompanies); + const selectedCompany = getCompanyInfo(safeSelectedId); + + if (isClient) { + return ` + + + `; + } + + return ` + + `; + } + + function createDeviceRecord({ name, sn, companyId }, allDevicesSnapshot) { + const companyInfo = getCompanyInfo(companyId); + const nextId = generateDeviceId(companyId, allDevicesSnapshot); + const serialNumber = normalizeSerialNumber(sn) || generateSerialNumber(allDevicesSnapshot); + const displayName = name || `${companyInfo.name} #${serialNumber.slice(-4)}`; + const record = normalizeDeviceRecord({ + id: nextId, + device_id: nextId, + name: displayName, + companyId, + companyName: companyInfo.name, + sn: serialNumber, + serial_number: serialNumber, + fw: getLatestFirmware(companyId), + status: 'online', + battery: 100, + lastSeen: '刚刚', + region: '-', + }); + allDevicesSnapshot.push(record); + return record; + } + + function syncDeviceMutation(companyId, addedCount = 0) { + window.__saveDeviceState?.(); + window.__syncCompanyDeviceCounts?.(); + window.__refreshCompanyNavigation?.(); + window.dispatchEvent(new CustomEvent('devicesChanged', { + detail: { companyId, addedCount }, + })); + } + + function parseBatchDeviceInput(rawText) { + const lines = String(rawText || '') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + + return lines + .filter((line, index) => !(index === 0 && /设备名称|sn\s*码|SN\s*码|serial|serial_number|device_id/i.test(line))) + .map((line, index) => { + const parts = line.split(/[,,\t]/).map(part => part.trim()).filter(Boolean); + if (parts.length === 1) { + return { lineNo: index + 1, name: '', sn: parts[0] }; + } + return { lineNo: index + 1, name: parts[0] || '', sn: parts[1] || '' }; + }); + } + + const devices = getLiveDevices(); + + // 根据当前公司过滤设备 + const filteredDevices = currentCompanyId === 'all' + ? devices + : devices.filter(d => d.companyId === currentCompanyId); + + // 统计信息 + const stats = { + total: filteredDevices.length, + online: filteredDevices.filter(d => d.status === 'online').length, + offline: filteredDevices.filter(d => d.status === 'offline').length, + error: filteredDevices.filter(d => d.status === 'error').length, + }; + + container.innerHTML = ` + + + + ${currentCompanyId === 'all' + ? `
+
+
+ +
+
+
设备范围
+
全部公司设备
+
汇总展示所有客户公司的设备状态,支持按公司下钻管理。
+
+
+
+ 设备总数${stats.total} + 在线率${stats.total ? Math.round((stats.online / stats.total) * 100) : 0}% +
+
` + : `
+
+
+ +
+
+
当前公司
+
${escapeHtml(currentCompany.name)}
+
当前仅展示该公司的设备数据,新增设备会默认绑定到该公司。
+
+
+
+ 最新固件${escapeHtml(getLatestFirmware(currentCompanyId))} + 在线率${stats.total ? Math.round((stats.online / stats.total) * 100) : 0}% +
+
` + } + + +
+
+
+ +
+
+
${stats.total}
+
设备总数
+
+
+
+
+ +
+
+
${stats.online}
+
在线数量
+
+
+
+
+ +
+
+
${stats.offline}
+
离线数量
+
+
+
+
+ +
+
+
${stats.error}
+
异常数量
+
+
+
+ + +
+
+
+ + +
+ + + +
+
+ + +
+
+ + + + + + + ${currentCompanyId === 'all' ? '' : ''} + + + + + + + + + ${renderDeviceRows(filteredDevices, currentCompanyId)} + +
设备名称device_idserial_number所属公司固件版本状态电量最后活跃操作
+
+
+ ${filteredDevices.length} 台设备 +
+
+ `; + + lucide.createIcons({ elements: [container] }); + + // 渲染设备行 + function renderDeviceRows(deviceList, companyId) { + if (deviceList.length === 0) { + return `暂无设备数据`; + } + + return deviceList.map(d => { + const safeActionId = escapeHtml(escapeInlineValue(d.id)); + const safeName = escapeHtml(d.name); + const safeDeviceId = escapeHtml(d.device_id || d.id); + const safeSn = escapeHtml(d.sn); + const safeCompanyName = escapeHtml(getCompanyName(d.companyId)); + const isLatest = d.fw === 'v2.3.1' || d.fw === 'v2.0.2' || d.fw === 'v1.8.2'; + const statusMap = { + online: { label: '在线', class: 'badge-success' }, + offline: { label: '离线', class: 'badge-warning' }, + error: { label: '异常', class: 'badge-danger' }, + }; + const status = statusMap[d.status] || statusMap.offline; + + return ` + + +
+ + ${safeName} +
+ + ${safeDeviceId} + ${safeSn} + ${companyId === 'all' ? `${safeCompanyName}` : ''} + + + ${escapeHtml(d.fw)} ${isLatest ? '' : '(可升级)'} + + + ${status.label} + +
+
+
+
+ ${d.battery}% +
+ + ${escapeHtml(d.lastSeen)} + + + + + `; + }).join(''); + } + + // 筛选设备 + window.filterDevices = function() { + const searchText = document.getElementById('deviceSearchInput')?.value.toLowerCase() || ''; + const statusFilter = document.getElementById('statusFilter')?.value || ''; + const versionFilter = document.getElementById('versionFilter')?.value || ''; + + const tableBody = document.getElementById('deviceTableBody'); + document.getElementById('deviceFilterEmptyRow')?.remove(); + const rows = document.querySelectorAll('#deviceTableBody tr.device-data-row'); + let visibleCount = 0; + + rows.forEach(row => { + const name = row.dataset.name || ''; + const sn = row.dataset.sn || ''; + const deviceUid = row.dataset.deviceUid || ''; + const status = row.dataset.status || ''; + const version = row.dataset.version || ''; + + const matchSearch = !searchText || name.includes(searchText) || sn.includes(searchText) || deviceUid.includes(searchText); + const matchStatus = !statusFilter || status === statusFilter; + const matchVersion = !versionFilter || + (versionFilter === 'latest' && (version === 'v2.3.1' || version === 'v2.0.2' || version === 'v1.8.2')) || + (versionFilter === 'outdated' && version !== 'v2.3.1' && version !== 'v2.0.2' && version !== 'v1.8.2'); + + const visible = matchSearch && matchStatus && matchVersion; + row.style.display = visible ? '' : 'none'; + if (visible) visibleCount++; + }); + + if (visibleCount === 0 && rows.length > 0 && tableBody) { + const emptyRow = document.createElement('tr'); + emptyRow.id = 'deviceFilterEmptyRow'; + emptyRow.innerHTML = `没有匹配的设备,请调整筛选条件`; + tableBody.appendChild(emptyRow); + } + + document.getElementById('deviceCount').textContent = visibleCount; + }; + + // 重置筛选 + window.resetDeviceFilters = function() { + const searchInput = document.getElementById('deviceSearchInput'); + const statusSelect = document.getElementById('statusFilter'); + const versionSelect = document.getElementById('versionFilter'); + if (searchInput) searchInput.value = ''; + if (statusSelect) statusSelect.value = ''; + if (versionSelect) versionSelect.value = ''; + filterDevices(); + }; + + // 导出设备 + window.exportDevices = function() { + const currentScopeLabel = currentCompanyId === 'all' ? '全部公司' : currentCompany.name; + const exportScopeOptions = getDeviceExportScopeOptions(); + const savePickerTip = window.showSaveFilePicker + ? '当前浏览器支持选择保存位置' + : '当前浏览器不支持选择保存位置,将自动改为浏览器下载'; + + const modal = createModal({ + title: '导出设备', + size: 'modal-lg', + content: ` +
+ +
+ ${exportScopeOptions.map((option, index) => ` + + `).join('')} +
+
+ 当前公司范围:${escapeHtml(currentScopeLabel)}。导出分类不受页面搜索和表格筛选影响。 +
+
+
+ +
+ + + + +
+
+
+ +
+ + + +
+
${savePickerTip}
+
+ +
+
+ 预计导出数量 + ${exportScopeOptions[0].count} 台 +
+
+ 导出字段 + device_id、设备名称、serial_number、公司、固件、状态、电量、最后活跃 +
+
+ `, + onConfirm: () => runDeviceExport(), + confirmText: '开始导出', + }); + + const syncExportModal = () => { + const destination = modal.overlay.querySelector('input[name="deviceExportDestination"]:checked')?.value || 'download'; + const format = modal.overlay.querySelector('input[name="deviceExportFormat"]:checked')?.value || 'xls'; + const scope = modal.overlay.querySelector('input[name="deviceExportScope"]:checked')?.value || 'all'; + const emailWrap = modal.overlay.querySelector('#deviceExportEmailWrap'); + const hint = modal.overlay.querySelector('#deviceExportHint'); + const previewCount = modal.overlay.querySelector('#deviceExportPreviewCount'); + const selectedCount = getDevicesForExportScope(scope).length; + + if (emailWrap) emailWrap.style.display = destination === 'email' ? '' : 'none'; + if (previewCount) previewCount.textContent = `${selectedCount} 台`; + if (hint) { + hint.textContent = format === 'pdf' && destination !== 'email' + ? 'PDF 会打开打印预览,可在系统打印窗口中保存为 PDF' + : destination === 'save-as' + ? savePickerTip + : destination === 'email' + ? '邮箱发送会走后端导出任务,当前前端先模拟任务提交' + : '文件会保存到浏览器默认下载位置'; + } + }; + + modal.overlay.querySelectorAll('input[name="deviceExportDestination"], input[name="deviceExportScope"], input[name="deviceExportFormat"]').forEach(input => { + input.addEventListener('change', syncExportModal); + }); + syncExportModal(); + lucide.createIcons({ elements: [modal.overlay] }); + }; + + function getDeviceExportScopeOptions() { + return [ + { value: 'all', label: '全部设备', count: filteredDevices.length }, + { value: 'online', label: '正常设备', count: filteredDevices.filter(device => device.status === 'online').length }, + { value: 'offline', label: '离线设备', count: filteredDevices.filter(device => device.status === 'offline').length }, + { value: 'error', label: '异常设备', count: filteredDevices.filter(device => device.status === 'error').length }, + ]; + } + + function getDevicesForExportScope(scope) { + if (scope === 'all') { + return filteredDevices; + } + + return filteredDevices.filter(device => device.status === scope); + } + + function getExportRows(deviceList) { + return deviceList.map(device => ({ + 'device_id': device.device_id || device.id, + '设备名称': device.name, + 'serial_number': device.serial_number || device.sn, + '所属公司': getCompanyName(device.companyId), + '固件版本': device.fw, + '状态': getStatusLabel(device.status), + '电量': `${device.battery}%`, + '最后活跃': device.lastSeen, + })); + } + + function csvCell(value) { + const text = String(value ?? ''); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; + } + + function buildExportFile(format, deviceList, scopeLabel) { + const rows = getExportRows(deviceList); + const columns = Object.keys(rows[0] || { + 'device_id': '', + '设备名称': '', + 'serial_number': '', + '所属公司': '', + '固件版本': '', + '状态': '', + '电量': '', + '最后活跃': '', + }); + const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + const baseName = `TalkingQ设备导出_${scopeLabel}_${timestamp}`.replace(/[\\/:*?"<>|]/g, '-'); + + if (format === 'json') { + return { + filename: `${baseName}.json`, + mime: 'application/json;charset=utf-8', + content: JSON.stringify({ + exportedAt: new Date().toISOString(), + scope: scopeLabel, + total: deviceList.length, + devices: rows, + }, null, 2), + }; + } + + if (format === 'csv') { + const csv = [ + columns.map(csvCell).join(','), + ...rows.map(row => columns.map(column => csvCell(row[column])).join(',')), + ].join('\n'); + return { + filename: `${baseName}.csv`, + mime: 'text/csv;charset=utf-8', + content: `\ufeff${csv}`, + }; + } + + if (format === 'pdf') { + return { + filename: `${baseName}.html`, + mime: 'text/html;charset=utf-8', + content: buildPrintableReport(rows, columns, scopeLabel), + printOnly: true, + }; + } + + const tableRows = rows.map(row => ` + ${columns.map(column => `${escapeHtml(row[column])}`).join('')} + `).join(''); + + return { + filename: `${baseName}.xls`, + mime: 'application/vnd.ms-excel;charset=utf-8', + content: ` + + + + + ${columns.map(column => ``).join('')} + ${tableRows} +
${escapeHtml(column)}
+ + + `, + }; + } + + function buildPrintableReport(rows, columns, scopeLabel) { + const tableRows = rows.map(row => ` + ${columns.map(column => `${escapeHtml(row[column])}`).join('')} + `).join(''); + + return ` + + + + + TalkingQ 设备导出报告 + + + +

TalkingQ 设备导出报告

+
范围:${escapeHtml(scopeLabel)} · 数量:${rows.length} 台 · 时间:${new Date().toLocaleString()}
+ + ${columns.map(column => ``).join('')} + ${tableRows} +
${escapeHtml(column)}
+ + + `; + } + + function downloadExportFile(file) { + const blob = new Blob([file.content], { type: file.mime }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = file.filename; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 500); + } + + async function saveExportFile(file) { + if (!window.showSaveFilePicker) { + Toast.warning('当前浏览器不支持选择保存位置,已改为浏览器下载'); + downloadExportFile(file); + return; + } + + const handle = await window.showSaveFilePicker({ + suggestedName: file.filename, + types: [{ + description: 'TalkingQ export', + accept: { [file.mime.split(';')[0]]: [`.${file.filename.split('.').pop()}`] }, + }], + }); + const writable = await handle.createWritable(); + await writable.write(new Blob([file.content], { type: file.mime })); + await writable.close(); + } + + function openPrintReport(file, printWindow = window.open('', '_blank')) { + if (!printWindow) { + Toast.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); + return false; + } + printWindow.document.open(); + printWindow.document.write(file.content); + printWindow.document.close(); + setTimeout(() => printWindow.print(), 300); + return true; + } + + async function runDeviceExport() { + const scope = document.querySelector('input[name="deviceExportScope"]:checked')?.value || 'all'; + const format = document.querySelector('input[name="deviceExportFormat"]:checked')?.value || 'xls'; + const destination = document.querySelector('input[name="deviceExportDestination"]:checked')?.value || 'download'; + const email = document.getElementById('deviceExportEmail')?.value.trim() || ''; + const exportList = getDevicesForExportScope(scope); + const currentScopeLabel = currentCompanyId === 'all' ? '全部公司' : currentCompany.name; + const scopeLabelMap = { + all: `${currentScopeLabel}-全部设备`, + online: `${currentScopeLabel}-正常设备`, + offline: `${currentScopeLabel}-离线设备`, + error: `${currentScopeLabel}-异常设备`, + }; + const scopeLabel = scopeLabelMap[scope] || '设备列表'; + + if (exportList.length === 0) { + Toast.warning('当前范围没有可导出的设备'); + return false; + } + + if (destination === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + Toast.warning('请输入有效的接收邮箱'); + return false; + } + + const file = destination !== 'email' ? buildExportFile(format, exportList, scopeLabel) : null; + const printWindow = file?.printOnly ? window.open('', '_blank') : null; + if (file?.printOnly && !printWindow) { + Toast.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); + return false; + } + + try { + await API.devices.export({ + ...(window.__getTenantScopeParams ? window.__getTenantScopeParams() : { companyId: currentCompanyId }), + scope, + format, + destination, + email: destination === 'email' ? email : undefined, + status: scope === 'all' ? undefined : scope, + count: exportList.length, + }); + } catch (error) { + if (printWindow && !printWindow.closed) printWindow.close(); + Toast.error('设备列表导出失败,请稍后重试'); + return false; + } + + if (destination === 'email') { + Toast.success(`导出任务已提交,完成后发送到 ${escapeHtml(email)}`); + return true; + } + + try { + if (file.printOnly) { + const opened = openPrintReport(file, printWindow); + if (!opened) return false; + Toast.success('已打开 PDF 打印预览,可选择保存为 PDF'); + return true; + } + + if (destination === 'save-as') { + await saveExportFile(file); + } else { + downloadExportFile(file); + } + Toast.success(`已导出 ${exportList.length} 台设备`); + } catch (error) { + if (error?.name === 'AbortError') { + Toast.info('已取消导出'); + } else { + Toast.error('文件保存失败,请稍后重试'); + } + return false; + } + }; + + // 添加设备弹窗 + window.openAddDeviceModal = function() { + if (isClient) { + Toast.warning('当前角色无权添加设备'); + return; + } + const defaultCompanyId = getDefaultDeviceCompanyId((window.companies || []).filter(company => company.id !== 'all')); + const companyField = buildDeviceCompanyField('newDeviceCompany', defaultCompanyId); + + createModal({ + title: '添加设备', + content: ` +
+
+ + +
+
+ + +
+
+
+ + ${companyField} +
+
+ + +
+ `, + onConfirm: async () => { + const name = document.getElementById('newDeviceName')?.value.trim(); + const sn = normalizeSerialNumber(document.getElementById('newDeviceSN')?.value); + const company = document.getElementById('newDeviceCompany')?.value; + const remark = document.getElementById('newDeviceRemark')?.value.trim() || ''; + + if (!name || !sn || !company) { + Toast.warning('请填写必填项'); + return false; + } + + if (!SERIAL_NUMBER_PATTERN.test(sn)) { + Toast.warning('serial_number 格式应为 TQ_ 加 16 位十六进制字符'); + return false; + } + + if (devices.some(device => normalizeSerialNumber(device.sn || device.serial_number) === sn)) { + Toast.warning('serial_number 已存在,请检查后再添加'); + return false; + } + + const allDevicesSnapshot = getLiveDevices().slice(); + const newDevice = createDeviceRecord({ name, sn, companyId: company }, allDevicesSnapshot); + + try { + await API.devices.create({ ...newDevice, remark }); + } catch (error) { + Toast.error('设备添加失败,请稍后重试'); + return false; + } + + window.__deviceAddedDevices.push(newDevice); + window.__deviceRemovedIds = window.__deviceRemovedIds.filter(id => id !== newDevice.id); + Toast.success(`设备 ${escapeHtml(name)} 添加成功`); + if (currentCompanyId !== 'all' && company !== currentCompanyId && window.switchCompany) { + syncDeviceMutation(company, 1); + window.switchCompany(company); + } else { + if (currentCompanyId !== 'all') window.currentCompany = company; + syncDeviceMutation(company, 1); + } + }, + confirmText: '添加', + }); + lucide.createIcons(); + }; + + window.openBatchAddDeviceModal = function() { + if (isClient) { + Toast.warning('当前角色无权批量添加设备'); + return; + } + const defaultCompanyId = getDefaultDeviceCompanyId((window.companies || []).filter(company => company.id !== 'all')); + const companyField = buildDeviceCompanyField('batchDeviceCompany', defaultCompanyId); + + createModal({ + title: '批量添加设备', + size: 'modal-lg', + content: ` +
+ + ${companyField} +
+ 批量导入的设备会统一绑定到该公司,新公司首次添加设备后会自动出现在设备统计与数据仪表盘中。 +
+
+
+ + +
+ 支持逗号、中文逗号或 Tab 分隔;serial_number 格式为 TQ_ 加 16 位十六进制字符。 +
+
+
+ 后端接入时建议先调用批量校验接口检查 serial_number 唯一性,再写入设备表并按 companyId 建立归属关系。 +
+ `, + confirmText: '批量添加', + onConfirm: async () => { + const company = document.getElementById('batchDeviceCompany')?.value; + const rows = parseBatchDeviceInput(document.getElementById('batchDeviceText')?.value || ''); + + if (!company) { + Toast.warning('请选择所属公司'); + return false; + } + if (rows.length === 0) { + Toast.warning('请填写至少一台设备'); + return false; + } + if (rows.length > 500) { + Toast.warning('单次最多批量添加 500 台设备'); + return false; + } + + const existingSnSet = new Set(getLiveDevices().map(device => normalizeSerialNumber(device.sn || device.serial_number))); + const batchSnSet = new Set(); + const invalidRows = []; + rows.forEach(row => { + const sn = normalizeSerialNumber(row.sn); + const snKey = sn; + if (!sn || !SERIAL_NUMBER_PATTERN.test(sn)) { + invalidRows.push(`第 ${row.lineNo} 行 serial_number 格式不正确`); + } else if (existingSnSet.has(snKey)) { + invalidRows.push(`第 ${row.lineNo} 行 serial_number 已存在`); + } else if (batchSnSet.has(snKey)) { + invalidRows.push(`第 ${row.lineNo} 行 serial_number 重复`); + } + batchSnSet.add(snKey); + }); + + if (invalidRows.length) { + Toast.warning(invalidRows.slice(0, 3).join(';') + (invalidRows.length > 3 ? '…' : '')); + return false; + } + + const allDevicesSnapshot = getLiveDevices().slice(); + const newDevices = rows.map(row => createDeviceRecord({ + name: row.name.trim(), + sn: normalizeSerialNumber(row.sn), + companyId: company, + }, allDevicesSnapshot)); + + try { + if (API.devices.batchCreate) { + await API.devices.batchCreate({ + companyId: company, + devices: newDevices, + tenantScope: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {}, + }); + } else { + await Promise.all(newDevices.map(device => API.devices.create(device))); + } + } catch (error) { + Toast.error('设备批量添加失败,请稍后重试'); + return false; + } + + window.__deviceAddedDevices.push(...newDevices); + newDevices.forEach(device => { + window.__deviceRemovedIds = window.__deviceRemovedIds.filter(id => id !== device.id); + }); + Toast.success(`已批量添加 ${newDevices.length} 台设备`); + if (currentCompanyId !== 'all' && company !== currentCompanyId && window.switchCompany) { + syncDeviceMutation(company, newDevices.length); + window.switchCompany(company); + } else { + if (currentCompanyId !== 'all') window.currentCompany = company; + syncDeviceMutation(company, newDevices.length); + } + }, + }); + lucide.createIcons(); + }; + + // 单设备OTA升级 + window.openSingleOTA = function(deviceId) { + if ((window.__getUserRole ? window.__getUserRole() : 'admin') === 'client') { + Toast.warning('当前角色无权访问 OTA 升级'); + return; + } + const device = devices.find(d => d.id === deviceId); + if (!device) { + Toast.warning('未找到该设备'); + return; + } + if (device?.companyId) { + window.currentCompany = device.companyId; + } + // 跳转到OTA页面进行升级 + Toast.info(`准备升级 ${escapeHtml(device.name)},当前版本: ${escapeHtml(device.fw)}`); + navigateTo('ota'); + }; + + // 查看设备详情 + window.viewDeviceDetail = function(deviceId) { + const device = devices.find(d => d.id === deviceId); + if (!device) { + Toast.warning('未找到该设备'); + return; + } + const safeDevice = { + id: escapeHtml(device.id), + name: escapeHtml(device.name), + deviceId: escapeHtml(device.device_id || device.id), + serialNumber: escapeHtml(device.serial_number || device.sn), + companyName: escapeHtml(getCompanyName(device.companyId)), + fw: escapeHtml(device.fw), + lastSeen: escapeHtml(device.lastSeen), + }; + + createModal({ + title: `设备详情 - ${safeDevice.name}`, + content: ` +
+
+ +
${safeDevice.deviceId}
+
+
+ +
${safeDevice.name}
+
+
+ +
${safeDevice.serialNumber}
+
+
+ +
${safeDevice.companyName}
+
+
+ +
${safeDevice.fw}
+
+
+ +
+ + ${device.status === 'online' ? '在线' : device.status === 'error' ? '异常' : '离线'} + +
+
+
+ +
+
+
+
+ ${device.battery}% +
+
+
+ +
${safeDevice.lastSeen}
+
+
+ `, + confirmText: '关闭', + showCancel: false, + }); + }; + + // 删除设备 + window.deleteDevice = function(deviceId) { + const device = devices.find(d => d.id === deviceId); + if (!device) return; + const safeName = escapeHtml(device.name); + + createModal({ + title: '确认删除', + confirmClass: 'btn-danger', + content: ` +
+ +

确认删除设备 ${safeName}?

+

设备将从当前列表移除,并无法再接收 OTA 升级。

+
+ `, + onConfirm: async () => { + try { + await API.devices.delete(deviceId); + } catch (error) { + Toast.error('设备删除失败,请稍后重试'); + return false; + } + if (!window.__deviceRemovedIds.includes(deviceId)) { + window.__deviceRemovedIds.push(deviceId); + adjustCompanyDeviceCount(device.companyId, -1); + } + syncDeviceMutation(device.companyId, -1); + Toast.success(`设备 ${safeName} 已删除`); + }, + confirmText: '确认删除', + }); + lucide.createIcons(); + }; +}); diff --git a/scripts/hotword-library.js b/scripts/hotword-library.js new file mode 100644 index 0000000..29ee1c6 --- /dev/null +++ b/scripts/hotword-library.js @@ -0,0 +1,333 @@ +/** + * TalkingQ Platform - Hotword Library Page + * 热词库管理:统一维护 ASR 热词组,供应用配置选择绑定。 + */ + +(function() { + function ensureHotwordGroups() { + if (!window.__agentHotwordGroups) { + window.__agentHotwordGroups = [ + { + id: 'hw-001', + name: '品牌基础热词', + mode: 'manual', + words: [ + { text: 'TalkingQ', lang: '英文' }, + { text: '小Q', lang: '中文' }, + { text: '故事机', lang: '中文' }, + ], + updatedAt: '2026-05-20 11:57:38', + }, + { + id: 'hw-002', + name: '儿童内容热词', + mode: 'manual', + words: [ + { text: '白雪公主', lang: '中文' }, + { text: '睡前故事', lang: '中文' }, + ], + updatedAt: '2026-05-20 12:08:16', + }, + ]; + } + return window.__agentHotwordGroups; + } + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value ?? ''; + return div.innerHTML; + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/"/g, '"').replace(/'/g, '''); + } + + function formatHotwordTime(date = new Date()) { + return date.toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }).replace(/\//g, '-'); + } + + function getHotwordPreview(group) { + const preview = group.words.slice(0, 2).map(word => word.text).join('、') || '暂无热词'; + return group.words.length > 2 ? `${preview} 等 ${group.words.length} 个` : `${preview} · ${group.words.length} 个`; + } + + function renderHotwordRows(words) { + if (!words.length) { + return `
点击“添加热词”开始配置,最多可添加 500 个热词。
`; + } + return words.map((word, index) => ` +
+ + + +
+ `).join(''); + } + + function renderHotwordGroupList(groups) { + if (!groups.length) { + return ` +
+ +

暂无热词组

+

创建热词组后,可在智能体配置里选择绑定。

+
+ `; + } + return groups.map(group => ` +
+
+
${escapeHtml(group.name)}
+
${escapeHtml(getHotwordPreview(group))}
+
+
${group.words.length} 个
+
${escapeHtml(group.updatedAt || '-')}
+
+ + +
+
+ `).join(''); + } + + function notifyHotwordGroupsChanged() { + window.dispatchEvent(new CustomEvent('hotwordGroupsChanged', { + detail: { groups: ensureHotwordGroups() }, + })); + } + + function openHotwordEditor(groupId, afterSave) { + const groups = ensureHotwordGroups(); + const editingGroup = groups.find(group => group.id === groupId); + let draftWords = editingGroup ? editingGroup.words.map(word => ({ ...word })) : []; + const modal = createModal({ + title: editingGroup ? '编辑热词组' : '配置热词库', + size: 'xl', + cancelText: '取消', + confirmText: '确定', + content: ` +
+
+ + +
${(editingGroup?.name || '').length}/50 字
+
+
+ + +
手动添加最多支持 500 个热词,每个热词最多 15 个字。
+
+
+
+
热词列表
+
${draftWords.length}/500 个
+
+ +
+
${renderHotwordRows(draftWords)}
+
+ `, + onConfirm: async () => { + const nameInput = document.getElementById('hotwordGroupNameInput'); + const groupName = nameInput?.value.trim() || ''; + if (!groupName) { + Toast.warning('请填写热词组名字'); + nameInput?.focus(); + return false; + } + if (groupName.length > 50) { + Toast.warning('热词组名字不能超过 50 个字'); + nameInput?.focus(); + return false; + } + const words = readHotwordRows(modal.overlay); + if (!words.length) { + Toast.warning('请至少添加一个热词'); + return false; + } + const invalid = words.find(word => !word.text || word.text.length > 15); + if (invalid) { + Toast.warning('请填写热词,且每个热词不能超过 15 个字'); + return false; + } + const now = formatHotwordTime(); + const payload = { name: groupName, mode: 'manual', words }; + const api = typeof API !== 'undefined' ? API : window.API; + let result = null; + try { + if (editingGroup && api?.agentDev?.updateHotwordGroup) { + await api.agentDev.updateHotwordGroup(editingGroup.id, payload); + } else if (!editingGroup && api?.agentDev?.createHotwordGroup) { + result = await api.agentDev.createHotwordGroup(payload); + } + } catch (error) { + console.error('Save hotword group failed', error); + Toast.error('热词组保存失败,请稍后重试'); + return false; + } + if (editingGroup) { + editingGroup.name = groupName; + editingGroup.mode = 'manual'; + editingGroup.words = words; + editingGroup.updatedAt = now; + } else { + groups.unshift({ + id: result?.data?.id || 'hw-' + Date.now(), + name: groupName, + mode: 'manual', + words, + updatedAt: now, + }); + } + notifyHotwordGroupsChanged(); + Toast.success(editingGroup ? '热词组已更新' : '热词组已创建'); + afterSave?.(); + }, + }); + + const overlay = modal.overlay; + overlay.querySelector('.modal')?.classList.add('hotword-editor-modal'); + const list = overlay.querySelector('#hotwordEntryList'); + const countHint = overlay.querySelector('#hotwordCountHint'); + + function readHotwordRows(scope = overlay) { + return Array.from(scope.querySelectorAll('.hotword-entry-row')).map(row => ({ + text: row.querySelector('.hotword-text-input')?.value.trim() || '', + lang: row.querySelector('.hotword-lang-select')?.value || '中文', + })); + } + + function rerenderRows() { + list.innerHTML = renderHotwordRows(draftWords); + countHint.textContent = `${draftWords.length}/500 个`; + bindRowEvents(); + lucide.createIcons({ elements: [overlay] }); + } + + function bindRowEvents() { + overlay.querySelectorAll('.hotword-row-delete').forEach(btn => { + btn.addEventListener('click', () => { + draftWords = readHotwordRows(); + draftWords.splice(Number(btn.dataset.index), 1); + rerenderRows(); + }); + }); + overlay.querySelectorAll('.hotword-text-input').forEach(input => { + input.addEventListener('input', () => { + if (input.value.length > 15) input.value = input.value.slice(0, 15); + }); + }); + } + + overlay.querySelector('#addHotwordRowBtn')?.addEventListener('click', () => { + draftWords = readHotwordRows(); + if (draftWords.length >= 500) { + Toast.warning('手动添加热词最多支持 500 个'); + return; + } + draftWords.push({ text: '', lang: '中文' }); + rerenderRows(); + list.scrollTop = list.scrollHeight; + overlay.querySelector('.hotword-entry-row:last-child .hotword-text-input')?.focus(); + }); + overlay.querySelector('#hotwordGroupNameInput')?.addEventListener('input', e => { + overlay.querySelector('#hotwordGroupNameHint').textContent = `${e.target.value.length}/50 字`; + }); + + bindRowEvents(); + lucide.createIcons({ elements: [overlay] }); + } + + window.__ensureAgentHotwordGroups = ensureHotwordGroups; + window.__getHotwordPreview = getHotwordPreview; + + registerPage('hotword-library', function(container) { + function renderPage() { + const groups = ensureHotwordGroups(); + container.innerHTML = ` + +
+
${renderHotwordGroupList(groups)}
+
+ `; + lucide.createIcons({ elements: [container] }); + bindEvents(); + } + + function bindEvents() { + container.querySelector('#createHotwordGroupPageBtn')?.addEventListener('click', () => { + openHotwordEditor(null, renderPage); + }); + container.querySelectorAll('.hotword-edit-btn').forEach(btn => { + btn.addEventListener('click', () => openHotwordEditor(btn.dataset.groupId, renderPage)); + }); + container.querySelectorAll('.hotword-delete-btn').forEach(btn => { + btn.addEventListener('click', () => { + const groups = ensureHotwordGroups(); + const group = groups.find(item => item.id === btn.dataset.groupId); + if (!group) return; + createModal({ + title: '删除热词组', + content: `

确定删除热词组 ${escapeHtml(group.name)} 吗?删除后已绑定的智能体将不再使用该热词组。

`, + confirmText: '删除', + onConfirm: async () => { + const api = typeof API !== 'undefined' ? API : window.API; + try { + if (api?.agentDev?.deleteHotwordGroup) { + await api.agentDev.deleteHotwordGroup(group.id); + } + } catch (error) { + console.error('Delete hotword group failed', error); + Toast.error('热词组删除失败,请稍后重试'); + return false; + } + const index = groups.findIndex(item => item.id === group.id); + if (index >= 0) groups.splice(index, 1); + if (window.__agentHotwordBindings) { + Object.keys(window.__agentHotwordBindings).forEach(appId => { + if (Array.isArray(window.__agentHotwordBindings[appId])) { + window.__agentHotwordBindings[appId] = window.__agentHotwordBindings[appId].filter(id => id !== group.id); + } + }); + } + notifyHotwordGroupsChanged(); + renderPage(); + Toast.success('热词组已删除'); + }, + }); + }); + }); + } + + renderPage(); + }); +})(); diff --git a/scripts/kb-create.js b/scripts/kb-create.js new file mode 100644 index 0000000..9f3caa6 --- /dev/null +++ b/scripts/kb-create.js @@ -0,0 +1,1467 @@ +/** + * TalkingQ Platform - 创建知识库向导页面 + * 包含3步流程:基础信息 -> 数据选择 -> 索引设置 + * + * API预留: + * - POST /api/v1/knowledge-bases - 创建知识库 + * - POST /api/v1/knowledge-bases/validate-name - 验证名称唯一性 + * - POST /api/v1/files/upload - 文件上传 + * - GET /api/v1/files/upload-status/:id - 获取上传状态 + */ + +registerPage('kb-create', function(container) { + // ==================== 状态管理 ==================== + let currentStep = 1; + const totalSteps = 3; + let hasUnsavedChanges = false; + const DRAFT_KEY = 'kb_create_draft'; + + // ==================== API预留 - 后端接口定义 ==================== + /** + * 创建知识库 + * POST /api/v1/knowledge-bases + * Body: { name, description, type, sourceType, sourceConfig, indexConfig } + * Response: { id: string, name: string, status: string } + */ + const API_CREATE = '/api/v1/knowledge-bases'; + + /** + * 验证名称唯一性 + * POST /api/v1/knowledge-bases/validate-name + * Body: { name: string } + * Response: { valid: boolean, message?: string } + */ + const API_VALIDATE_NAME = '/api/v1/knowledge-bases/validate-name'; + + /** + * 文件上传 + * POST /api/v1/files/upload + * FormData: { file, category, knowledgeBaseId? } + * Response: { fileId: string, fileName: string, status: string } + */ + const API_UPLOAD = '/api/v1/files/upload'; + + /** + * 获取上传/解析状态 + * GET /api/v1/files/status/:fileId + * Response: { status: 'uploading'|'processing'|'ready'|'error', progress?: number, error?: string } + */ + const API_FILE_STATUS = '/api/v1/files/status/:fileId'; + + /** + * 获取类目列表 + * GET /api/v1/categories + * Response: { list: [{ id, name, fileCount }] } + */ + const API_GET_CATEGORIES = '/api/v1/categories'; + + // ==================== 表单数据 ==================== + const formData = { + // 步骤1: 基础信息 + name: '', + description: '', + type: '', + + // 步骤2: 数据选择 + sourceType: 'upload', // upload | category | files + category: '', + uploadedFiles: [], + autoSync: false, + parseMethod: 'inherit', // inherit | default | custom + + // 自定义解析配置 + docParseMethods: ['electronic'], + imageParseMethods: ['smart'], + videoParseMethods: ['media'], + enableVideoParse: false, + + // 步骤3: 索引设置 + sliceMethod: 'smart', // smart | length | page | title | regex | symbol + maxChunkLength: 2000, + overlapLength: 200, + titleLevel: 3, + regexPattern: '', + symbolChars: ',', + enableMetadataExtract: true, + enableExcelHeader: false, + enableMultiTurn: false, + enableOCR: false, + enableAutoKeywords: false, + enableAutoTags: false, + vectorModel: 'text-embedding-v2', + rerankModel: 'bge-reranker-v2.5', + rerankMode: 'qa', // qa | similarity | custom + vectorTopK: 60, + keywordTopK: 60, + similarityThreshold: 0.5, + maxRecallCount: 10, + vectorStorage: 'platform' + }; + + // ==================== 知识库类型定义 ==================== + const kbTypes = [ + { + id: 'doc_search', + icon: 'file-text', + title: '文档搜索', + desc: '构建文档、文件、图片、Excel混合型索引知识库', + tooltip: '构建文档、文件、图片、Excel混合型索引知识库,构建常规混合搜索知识库。支持PDF、Word、Excel、图片等多种格式文档。' + }, + { + id: 'data_query', + icon: 'table', + title: '数据查询', + desc: '基于NL2SQL的表格数据查询', + tooltip: '构建仅以数据表结构查询的数据索引体系,基于NL2SQL思路,实现表头、列头等结构化数据查询能力。适合结构化数据问答场景。' + }, + { + id: 'image_qa', + icon: 'image', + title: '图片问答', + desc: '基于多模态Embedding的图片检索', + tooltip: '构建仅以图片索引为主的知识库,基于多模态Embedding能力,实现图片名称与图片内容搜索。适合图片素材检索场景。' + }, + { + id: 'video_search', + icon: 'video', + title: '音视频搜索', + desc: '音视频内容智能理解', + tooltip: '构建音视频搜索知识库,基于高精度解析与多模态Embedding检索技术,对音视频内容进行智能理解与提炼。支持字幕提取、内容摘要等。' + } + ]; + + // ==================== 切片方式 ==================== + const sliceMethods = [ + { id: 'smart', label: '智能切分', desc: '根据语义自动识别段落边界' }, + { id: 'length', label: '按长度切分', desc: '固定长度切分,支持重叠' }, + { id: 'page', label: '按页切分', desc: '按页分割文档' }, + { id: 'title', label: '按标题切分', desc: '按标题层级结构切分' }, + { id: 'regex', label: '按正则切分', desc: '自定义正则表达式切分' }, + { id: 'symbol', label: '按符号切分', desc: '按标点符号切分' } + ]; + + // ==================== 符号选项 ==================== + const symbolOptions = [ + { value: ',', label: '中文逗号 ,' }, + { value: '。', label: '中文句号 。' }, + { value: ',', label: '中文逗号(英文)' }, + { value: '.', label: '英文句号 .' }, + { value: '、', label: '顿号 、' }, + { value: '?', label: '中文问号 ?' }, + { value: '!', label: '中文感叹号 !' }, + { value: '?', label: '英文问号 ?' }, + { value: '!', label: '英文感叹号 !' }, + { value: '\n', label: '换行符' }, + { value: '\n\n', label: '双换行符' } + ]; + + // ==================== 类目数据 ==================== + const categories = [ + { id: 'cat-001', name: '默认类目', fileCount: 128 }, + { id: 'cat-002', name: '教学资料', fileCount: 56 }, + { id: 'cat-003', name: '产品文档', fileCount: 34 }, + { id: 'cat-004', name: '用户指南', fileCount: 89 }, + ]; + + // ==================== 工具函数 ==================== + + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function getFormatsByType() { + switch (formData.type) { + case 'image_qa': + return '.png,.jpg,.jpeg,.bmp,.gif'; + case 'video_search': + return '.mp4,.avi,.mov,.mkv,.wmv,.mp3,.wav,.m4a'; + case 'data_query': + return '.xlsx,.xls,.csv'; + default: + return '.pdf,.doc,.docx,.txt,.md,.ppt,.pptx,.xlsx,.xls,.png,.jpg,.jpeg,.gif'; + } + } + + function getFileIcon(filename) { + const ext = filename?.split('.').pop()?.toLowerCase() || ''; + const icons = { + pdf: '📄', doc: '📄', docx: '📄', txt: '📝', md: '📋', + xlsx: '📊', xls: '📊', csv: '📊', + png: '🖼', jpg: '🖼', jpeg: '🖼', gif: '🖼', bmp: '🖼', + mp4: '🎬', avi: '🎬', mov: '🎬', mkv: '🎬', wmv: '🎬', + mp3: '🎵', wav: '🎵', m4a: '🎵' + }; + return icons[ext] || '📁'; + } + + function getFileStatusText(status) { + const map = { + pending: '待上传', + uploading: '上传中', + processing: '解析中', + ready: '解析完成', + error: '解析失败' + }; + return map[status] || status; + } + + // ==================== 草稿管理 ==================== + + function loadDraft() { + try { + const draft = localStorage.getItem(DRAFT_KEY); + if (draft) { + const saved = JSON.parse(draft); + Object.assign(formData, saved.formData || {}); + currentStep = saved.currentStep || 1; + return true; + } + } catch (e) { + console.error('Failed to load draft:', e); + } + return false; + } + + function saveDraft() { + try { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ + formData, + currentStep, + savedAt: new Date().toISOString() + })); + Toast.success('草稿已保存'); + } catch (e) { + Toast.warning('草稿保存失败'); + } + } + + function clearDraft() { + localStorage.removeItem(DRAFT_KEY); + } + + // ==================== 验证函数 ==================== + + function validateStep1() { + const errors = []; + + if (!formData.name.trim()) { + errors.push('请输入知识库名称'); + } else if (formData.name.length < 2 || formData.name.length > 64) { + errors.push('知识库名称长度需在2-64字符之间'); + } + + if (!formData.type) { + errors.push('请选择知识库类型'); + } + + return errors; + } + + function validateStep2() { + const errors = []; + + if (formData.sourceType === 'upload' && formData.uploadedFiles.length === 0) { + errors.push('请上传至少一个文件'); + } + + if (formData.sourceType === 'upload') { + const failedFiles = formData.uploadedFiles.filter(f => f.status === 'error'); + if (failedFiles.length > 0) { + errors.push(`有 ${failedFiles.length} 个文件上传失败,请处理后重试`); + } + } + + return errors; + } + + function validateStep3() { + return []; // 步骤3一般不需要强制验证 + } + + // ==================== 渲染函数 ==================== + + /** + * 渲染向导步骤 + */ + function renderWizardSteps() { + const steps = ['基础信息', '数据选择', '索引设置']; + return ` +
+ ${[1, 2, 3].map((step, idx) => ` +
+
+ ${step < currentStep ? '' : step} +
+ ${steps[step - 1]} +
+ ${idx < 2 ? `
` : ''} + `).join('')} +
+ `; + } + + /** + * 渲染步骤1:基础信息 + */ + function renderStep1() { + return ` +
+

+ 基础知识信息 + ? +

+ + + + +
+ + +
${formData.name.length}/64
+
+ + +
+ + + +
${formData.description.length}/500
+
+ + +
+ +
+ ${kbTypes.map(type => ` +
+
+
+
${type.title}
+
${type.desc}
+ +
+ `).join('')} +
+
+ + +
+ `; + } + + /** + * 渲染步骤2:数据选择 + */ + function renderStep2() { + const formats = getFormatsByType(); + const maxFiles = 50; + const maxSize = formData.type === 'image_qa' || formData.type === 'video_search' ? '20MB' : '50MB'; + + return ` +
+

选择数据源

+ + +
+ +
+ 默认文件连接器 +

从连接器内选择已解析文档构建知识索引,也支持直接上传文件解析并构建索引,上传文件将自动存储至连接器。

+
+
+ + +
+
+ + 文件上传 +
+
+ + 选择类目 +
+
+ + 选择文件 +
+
+ +
+ ${renderSourceContent(formats, maxFiles, maxSize)} +
+ + + ${formData.sourceType !== 'upload' ? ` +
+
+
+ 开启自动同步知识索引 + ? +
+ +
+ 开启后将自动同步类目下所有文件,在类目文档发生变化后,自动重建及更新索引。 +
+
+ +
+ ` : ''} +
+ `; + } + + /** + * 渲染数据源内容 + */ + function renderSourceContent(formats, maxFiles, maxSize) { + switch (formData.sourceType) { + case 'upload': + return renderUploadContent(formats, maxFiles, maxSize); + case 'category': + return renderCategoryContent(); + case 'files': + return renderFilesContent(); + default: + return renderUploadContent(formats, maxFiles, maxSize); + } + } + + /** + * 渲染文件上传内容 + */ + function renderUploadContent(formats, maxFiles, maxSize) { + return ` + +
+ 配置类目: + + +
+ + +
+
📁
+
点击或拖拽文件到此处上传
+
+ + +
+ + +
+ 支持格式: ${formats}
+ 单文件最大: ${maxSize} | 最多: ${maxFiles}个文件 +
+
+ + + ${formData.uploadedFiles.length > 0 ? ` +
+
+ 文件名 + 大小 + 状态 + 操作 +
+ ${formData.uploadedFiles.map((file, idx) => ` +
+
${getFileIcon(file.name)}
+
+
${escapeHtml(file.name)}
+
${file.size}
+
+ + ${file.status === 'uploading' ? `` : ''} + ${file.status === 'processing' ? `` : ''} + ${getFileStatusText(file.status)} + +
+ ${file.status === 'error' ? ` + + ` : ''} + +
+
+ `).join('')} +
+
+ 已选择 ${formData.uploadedFiles.length}/${maxFiles} 个文件 + +
+ ` : ''} + + +
+
+ 解析方式 + ? +
+ +
+
+ + 继承目录配置 +
+
+ + 默认解析 +
+
+ + 自定义解析 +
+
+ + ${formData.parseMethod === 'custom' ? ` +
+ ${renderParseMethodDetail()} +
+ ` : ''} +
+ `; + } + + /** + * 渲染自定义解析方式 + */ + function renderParseMethodDetail() { + const type = formData.type; + let html = '
自定义解析方式:
'; + + if (['doc_search', 'data_query', ''].includes(type)) { + html += ` +
+
文档类型
+
+
+ + 电子文档解析 +
+
+ + Markdown解析 +
+
+ + HTML解析 +
+
+ + PPT文档智能解析 +
+
+ + 大模型文档解析 +
+
+
+ `; + } + + if (type === 'image_qa' || !type) { + html += ` +
+
图片类型
+
+
+ + 文档智能解析 +
+
+ + 大模型文档解析 +
+
+ + Qwen VL解析 +
+
+
+ `; + } + + if (type === 'video_search' || !type) { + html += ` +
+
音视频类型
+
+
+ + 音视频解析 +
+ +
+
+ `; + } + + return html; + } + + /** + * 渲染类目选择内容 + */ + function renderCategoryContent() { + return ` +
+ 选择类目: + + + +
+ +
+ +
+

选择类目后,该类目下所有文件将被导入并构建索引。

+

当前类目包含 128 个文件,预计解析时间约 5-10 分钟。

+
+
+ `; + } + + /** + * 渲染文件选择内容 + */ + function renderFilesContent() { + const appFiles = [ + { name: '产品说明书.pdf', size: '3.2MB', format: 'PDF' }, + { name: '用户指南.docx', size: '1.5MB', format: 'DOCX' }, + { name: '技术规格.xlsx', size: '856KB', format: 'XLSX' }, + { name: '培训视频.mp4', size: '128MB', format: 'MP4' }, + { name: '常见问题.md', size: '245KB', format: 'MD' }, + ]; + + return ` +
+ 应用数据类目: + +
+ +
+
+
+ + 数据中心文件 +
+
+ ${appFiles.map((f, idx) => ` +
+ +
${getFileIcon(f.name)}
+
+
${f.name}
+
${f.format} · ${f.size}
+
+
+ `).join('')} +
+
+
+
+ + 已选择 0/50 +
+
+
+ +

从左侧选择文件

+

最多支持50个文件

+
+
+
+
+ `; + } + + /** + * 渲染步骤3:索引设置 + */ + function renderStep3() { + return ` +
+

+ 索引设置 + ? +

+ + + + +
+ + +
+ + +
+ ${renderSliceConfig()} +
+ + +
+
+ + + 高级参数配置 + + +
+
+ +
+ +
+
+ + Metadata抽取 + ? +
+
+ + Excel表头拼装 +
+
+ + 多轮对话改写 +
+
+ + OCR增强 +
+
+ + 自动关键词提取 +
+
+ + 自动标签提取 +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + + +
+ + +
+
检索参数配置
+ +
+ +
+ + ${formData.vectorTopK} + (10-100) +
+
+ +
+ +
+ + ${formData.keywordTopK} + (10-100) +
+
+ +
+ +
+ + ${formData.similarityThreshold.toFixed(2)} + (0.01-1) +
+
+ +
+ +
+ + ${formData.maxRecallCount} + (1-20) +
+
+
+ + +
+
+ + +
+ + +
+ +
+ + 系统会自动保存草稿,您可以随时中断并继续 +
+
+ `; + } + + /** + * 渲染切片配置 + */ + function renderSliceConfig() { + switch (formData.sliceMethod) { + case 'smart': + return ` +
+
+ 最大分段长度 + ? +
+
+ + 字符 (10-6000) +
+
+ + `; + case 'length': + return ` +
+
+ 分段预估长度 + ? +
+
+ + 字符 (10-6000) +
+
+ +
+
+ 分段重叠长度 + ? +
+
+ + 字符 (0-1024) +
+
+ + `; + case 'page': + return ` +
+
+ 最大分段长度 +
+
+ + 字符 (10-6000) +
+
+ `; + case 'title': + return ` +
+
最大分段长度
+
+ + 字符 (10-6000) +
+
+
+
标题级数
+
+ + 级 (1-5) +
+
+ `; + case 'regex': + return ` +
+
正则表达式
+
+ +
+
+
+
最大分段长度
+
+ + 字符 (10-6000) +
+
+ `; + case 'symbol': + return ` +
+
符号选择器
+
+ +
+
+
+
最大分段长度
+
+ + 字符 (10-6000) +
+
+ `; + default: + return ''; + } + } + + // ==================== 渲染页面 ==================== + + function renderPage() { + container.innerHTML = ` +
+ + + + ${renderWizardSteps()} + +
+ ${currentStep === 1 ? renderStep1() : currentStep === 2 ? renderStep2() : renderStep3()} +
+ + +
+ `; + + lucide.createIcons({ elements: [container] }); + } + + // ==================== 步骤控制 ==================== + + function collectStepData() { + if (currentStep === 1) { + formData.name = document.getElementById('kbName')?.value.trim() || ''; + formData.description = document.getElementById('kbDesc')?.value.trim() || ''; + } + } + + window.__prevStep = function() { + if (currentStep > 1) { + collectStepData(); + currentStep--; + renderPage(); + } + }; + + window.__nextStep = function() { + collectStepData(); + + let errors = []; + if (currentStep === 1) { + errors = validateStep1(); + } else if (currentStep === 2) { + errors = validateStep2(); + } + + if (errors.length > 0) { + Toast.error(errors[0]); + return; + } + + if (currentStep < totalSteps) { + currentStep++; + renderPage(); + } + }; + + window.__finishWizard = function() { + // TODO: 调用 API POST /api/v1/knowledge-bases 创建知识库 + console.log('创建知识库数据:', formData); + + clearDraft(); + Toast.success('知识库创建成功!'); + + setTimeout(() => { + navigateTo('knowledge-base'); + }, 1000); + }; + + // ==================== 全局函数 ==================== + + window.__updateCharCount = function(input, max) { + const counter = input.closest('.form-group')?.querySelector('.char-counter'); + if (counter) { + counter.textContent = `${input.value.length}/${max}`; + counter.classList.toggle('warning', input.value.length >= max * 0.9); + } + hasUnsavedChanges = true; + }; + + window.__selectKbType = function(typeId) { + formData.type = typeId; + hasUnsavedChanges = true; + document.querySelectorAll('.kb-type-card').forEach(card => { + card.classList.toggle('selected', card.dataset.type === typeId); + }); + }; + + window.__showKbTypeHelp = function() { + createModal({ + title: '知识库类型说明', + content: ` +
+ ${kbTypes.map(type => ` +
+
+
+ +
+ ${type.title} +
+
${type.tooltip}
+
+ `).join('')} +
+ `, + confirmText: '', + cancelText: '关闭' + }); + lucide.createIcons(); + }; + + window.__selectSourceType = function(type) { + formData.sourceType = type; + hasUnsavedChanges = true; + const formats = getFormatsByType(); + const maxFiles = 50; + const maxSize = formData.type === 'image_qa' || formData.type === 'video_search' ? '20MB' : '50MB'; + document.getElementById('sourceContent').innerHTML = renderSourceContent(formats, maxFiles, maxSize); + lucide.createIcons(); + }; + + window.__handleDragOver = function(e) { + e.preventDefault(); + e.currentTarget.classList.add('dragover'); + }; + + window.__handleDragLeave = function(e) { + e.currentTarget.classList.remove('dragover'); + }; + + window.__handleDrop = function(e) { + e.preventDefault(); + e.currentTarget.classList.remove('dragover'); + window.__handleFileSelect(e.dataTransfer.files); + }; + + window.__handleFileSelect = function(files) { + if (!files || !files.length) return; + + const maxFiles = 50; + const maxSize = formData.type === 'image_qa' || formData.type === 'video_search' ? 20 * 1024 * 1024 : 50 * 1024 * 1024; + + Array.from(files).forEach(file => { + if (formData.uploadedFiles.length >= maxFiles) { + Toast.warning(`最多只能上传${maxFiles}个文件`); + return; + } + if (file.size > maxSize) { + Toast.warning(`文件 ${file.name} 超过大小限制 (${maxSize / 1024 / 1024}MB)`); + return; + } + + const fileObj = { + name: file.name, + size: (file.size / 1024 / 1024).toFixed(2) + 'MB', + status: 'uploading', + file: file + }; + formData.uploadedFiles.push(fileObj); + hasUnsavedChanges = true; + + // TODO: 调用 API POST /api/v1/files/upload 上传文件 + // 模拟上传进度 + setTimeout(() => { + fileObj.status = 'processing'; + renderPage(); + }, 500); + + setTimeout(() => { + // 模拟90%成功率 + fileObj.status = Math.random() > 0.1 ? 'ready' : 'error'; + renderPage(); + }, 2000); + }); + + renderPage(); + }; + + window.__removeFile = function(index) { + formData.uploadedFiles.splice(index, 1); + hasUnsavedChanges = true; + renderPage(); + }; + + window.__retryUpload = function(index) { + if (formData.uploadedFiles[index]) { + formData.uploadedFiles[index].status = 'uploading'; + renderPage(); + } + }; + + window.__clearAllFiles = function() { + formData.uploadedFiles = []; + hasUnsavedChanges = true; + renderPage(); + }; + + window.__updateCategory = function(value) { + formData.category = value; + hasUnsavedChanges = true; + }; + + window.__selectParseMethod = function(method) { + formData.parseMethod = method; + hasUnsavedChanges = true; + document.querySelectorAll('.parse-method-option').forEach(opt => { + const isSelected = opt.textContent.includes( + method === 'inherit' ? '继承' : method === 'default' ? '默认' : '自定义' + ); + opt.classList.toggle('selected', isSelected); + }); + const detail = document.getElementById('parseMethodDetail'); + if (detail) { + detail.innerHTML = renderParseMethodDetail(); + } + }; + + window.__toggleParseMethod = function(type, method) { + const key = type === 'doc' ? 'docParseMethods' : type === 'image' ? 'imageParseMethods' : 'videoParseMethods'; + const arr = formData[key]; + const idx = arr.indexOf(method); + if (idx > -1) { + arr.splice(idx, 1); + } else { + arr.push(method); + } + hasUnsavedChanges = true; + const detail = document.getElementById('parseMethodDetail'); + if (detail) { + detail.innerHTML = renderParseMethodDetail(); + lucide.createIcons({ elements: [detail] }); + } + }; + + window.__toggleAutoSync = function(checked) { + formData.autoSync = checked; + hasUnsavedChanges = true; + Toast.info(checked ? '已开启自动同步' : '已关闭自动同步'); + }; + + window.__updateSliceConfig = function() { + formData.sliceMethod = document.getElementById('sliceMethod').value; + hasUnsavedChanges = true; + document.getElementById('sliceConfigPanel').innerHTML = renderSliceConfig(); + lucide.createIcons(); + }; + + window.__updateRangeValue = function(input, field, multiplier = 1) { + const value = parseInt(input.value); + const displayEl = document.getElementById(field + 'Value'); + if (displayEl) { + displayEl.textContent = multiplier > 1 ? (value / multiplier).toFixed(2) : value; + } + formData[field] = multiplier > 1 ? value / multiplier : value; + hasUnsavedChanges = true; + }; + + window.__resetRetrievalParams = function() { + formData.vectorTopK = 60; + formData.keywordTopK = 60; + formData.similarityThreshold = 0.5; + formData.maxRecallCount = 10; + hasUnsavedChanges = true; + renderPage(); + }; + + window.__toggleEnhance = function(key) { + formData['enable' + key.charAt(0).toUpperCase() + key.slice(1)] = !formData['enable' + key.charAt(0).toUpperCase() + key.slice(1)]; + hasUnsavedChanges = true; + const advancedBody = document.querySelector('.advanced-config-body'); + if (advancedBody) { + const options = advancedBody.querySelectorAll('.parse-method-option'); + options.forEach(opt => { + if (opt.textContent.includes('Metadata抽取')) { + opt.classList.toggle('selected', formData.enableMetadataExtract); + } else if (opt.textContent.includes('Excel表头')) { + opt.classList.toggle('selected', formData.enableExcelHeader); + } else if (opt.textContent.includes('多轮对话')) { + opt.classList.toggle('selected', formData.enableMultiTurn); + } else if (opt.textContent.includes('OCR')) { + opt.classList.toggle('selected', formData.enableOCR); + } else if (opt.textContent.includes('关键词')) { + opt.classList.toggle('selected', formData.enableAutoKeywords); + } else if (opt.textContent.includes('标签')) { + opt.classList.toggle('selected', formData.enableAutoTags); + } + }); + } + }; + + window.__showTooltip = function(id) { + const tooltip = document.getElementById(id); + if (tooltip) { + tooltip.style.display = tooltip.style.display === 'none' ? 'block' : 'none'; + } + }; + + window.__toggleFileSelect = function(el, filename) { + const checkbox = el.querySelector('input[type="checkbox"]'); + checkbox.checked = !checkbox.checked; + el.classList.toggle('selected', checkbox.checked); + Toast.info(checkbox.checked ? `已选择: ${filename}` : `已取消: ${filename}`); + }; + + window.__loadCategoryFiles = function(category) { + // TODO: 调用 API GET /api/v1/categories/:id/files + Toast.info(`加载 ${category} 文件列表`); + }; + + window.__showCategoryDrawer = function() { + Toast.info('类目管理功能'); + }; + + window.__saveDraft = saveDraft; + + window.__confirmLeave = function() { + if (hasUnsavedChanges) { + createModal({ + title: '确认离开', + content: ` +
+

确定要离开吗?

+

您有未保存的更改,离开后将丢失。

+
+ `, + confirmText: '离开', + confirmClass: 'btn-danger', + onConfirm: () => { + navigateTo('knowledge-base'); + } + }); + } else { + navigateTo('knowledge-base'); + } + }; + + // ==================== 初始化 ==================== + + loadDraft(); + renderPage(); + + // 离开页面确认 + window.addEventListener('beforeunload', function(e) { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; + } + }); +}); diff --git a/scripts/kb-detail.js b/scripts/kb-detail.js new file mode 100644 index 0000000..46c228c --- /dev/null +++ b/scripts/kb-detail.js @@ -0,0 +1,1233 @@ +/** + * TalkingQ Platform - 知识库详情页面 + * 包含文件管理、标签管理、切片管理、命中测试等功能 + * + * API预留: + * - GET /api/v1/knowledge-bases/:id - 获取知识库详情 + * - GET /api/v1/knowledge-bases/:id/files - 获取文件列表 + * - POST /api/v1/knowledge-bases/:id/files - 上传文件 + * - DELETE /api/v1/knowledge-bases/:id/files/:fileId - 删除文件 + * - POST /api/v1/knowledge-bases/:id/files/:fileId/reparse - 重新解析 + * - GET /api/v1/knowledge-bases/:id/slices - 获取切片列表 + * - PUT /api/v1/knowledge-bases/:id/slices/:sliceId - 更新切片 + * - GET /api/v1/knowledge-bases/:id/hit-test - 命中测试 + * - GET /api/v1/knowledge-bases/:id/metadata - 获取Metadata统计 + */ + +registerPage('kb-detail', function(container) { + // ==================== 状态管理 ==================== + const urlParams = new URLSearchParams(window.location.search); + const kbId = urlParams.get('id') || 'kb-001'; + const initialTab = urlParams.get('tab') || 'files'; + + let currentTab = initialTab; + let selectedFiles = new Set(); + let isLoading = true; + + // ==================== API预留 - 后端接口定义 ==================== + /** + * 获取知识库详情 + * GET /api/v1/knowledge-bases/:id + * Response: { id, name, description, type, status, docs, size, tags, createdAt, updatedAt } + */ + const API_GET_DETAIL = '/api/v1/knowledge-bases/:id'; + + /** + * 获取文件列表 + * GET /api/v1/knowledge-bases/:id/files + * Query: { page, pageSize, search, status } + * Response: { list: [], total, page, pageSize } + */ + const API_GET_FILES = '/api/v1/knowledge-bases/:id/files'; + + /** + * 上传文件 + * POST /api/v1/knowledge-bases/:id/files + * FormData: { file, category } + * Response: { fileId, fileName, status } + */ + const API_UPLOAD_FILE = '/api/v1/knowledge-bases/:id/files'; + + /** + * 删除文件 + * DELETE /api/v1/knowledge-bases/:id/files/:fileId + * Response: { success: true } + */ + const API_DELETE_FILE = '/api/v1/knowledge-bases/:id/files/:fileId'; + + /** + * 重新解析文件 + * POST /api/v1/knowledge-bases/:id/files/:fileId/reparse + * Response: { success: true } + */ + const API_REPARSE_FILE = '/api/v1/knowledge-bases/:id/files/:fileId/reparse'; + + /** + * 获取切片列表 + * GET /api/v1/knowledge-bases/:id/slices + * Query: { page, pageSize, search, fileId } + * Response: { list: [], total, page, pageSize } + */ + const API_GET_SLICES = '/api/v1/knowledge-bases/:id/slices'; + + /** + * 更新切片 + * PUT /api/v1/knowledge-bases/:id/slices/:sliceId + * Body: { content: string } + * Response: { success: true, chars: number } + */ + const API_UPDATE_SLICE = '/api/v1/knowledge-bases/:id/slices/:sliceId'; + + /** + * 获取切片历史版本 + * GET /api/v1/knowledge-bases/:id/slices/:sliceId/history + * Response: { list: [{ version, content, updatedAt, updatedBy }] } + */ + const API_SLICE_HISTORY = '/api/v1/knowledge-bases/:id/slices/:sliceId/history'; + + /** + * 回滚切片 + * POST /api/v1/knowledge-bases/:id/slices/:sliceId/rollback + * Body: { version: number } + * Response: { success: true } + */ + const API_ROLLBACK_SLICE = '/api/v1/knowledge-bases/:id/slices/:sliceId/rollback'; + + /** + * 命中测试 + * GET /api/v1/knowledge-bases/:id/hit-test + * Query: { query, topK, similarityThreshold, mode } + * Response: { results: [{ content, source, score, chars, metadata }] } + */ + const API_HIT_TEST = '/api/v1/knowledge-bases/:id/hit-test'; + + /** + * 获取测试历史 + * GET /api/v1/knowledge-bases/:id/test-history + * Response: { list: [{ id, time, query, recallCount, avgScore }] } + */ + const API_TEST_HISTORY = '/api/v1/knowledge-bases/:id/test-history'; + + /** + * 获取Metadata统计 + * GET /api/v1/knowledge-bases/:id/metadata + * Response: { tags: [], categories: [], formats: [] } + */ + const API_GET_METADATA = '/api/v1/knowledge-bases/:id/metadata'; + + /** + * 重建索引 + * POST /api/v1/knowledge-bases/:id/rebuild + * Response: { success: true, taskId } + */ + const API_REBUILD_INDEX = '/api/v1/knowledge-bases/:id/rebuild'; + + /** + * 删除知识库 + * DELETE /api/v1/knowledge-bases/:id + * Response: { success: true } + */ + const API_DELETE_KB = '/api/v1/knowledge-bases/:id'; + + // ==================== 模拟数据 ==================== + const kbData = { + id: kbId, + name: '儿童科学百科', + description: '包含各类儿童科学知识的百科全书,包括物理、化学、生物等基础科学知识', + type: 'doc_search', + typeName: '文档搜索', + tags: ['儿童', '科学', '教育'], + docs: 2840, + size: '128MB', + status: 'ready', + createdAt: '2026-05-01 10:00:00', + updatedAt: '2026-05-10 14:32:15', + }; + + let kbFiles = [ + { id: 'f1', name: '儿童科学百科.pdf', size: '2.5MB', format: 'PDF', dataSize: '2.5MB', status: 'ready', category: '默认类目', indexedAt: '2026-05-10 14:32:15', slices: 128, chars: 45678, parseError: null }, + { id: 'f2', name: '科学小实验.docx', size: '1.8MB', format: 'DOCX', dataSize: '1.8MB', status: 'processing', category: '教学类目', indexedAt: '-', slices: 45, chars: 12340, parseError: null }, + { id: 'f3', name: '物理入门.xlsx', size: '856KB', format: 'XLSX', dataSize: '856KB', status: 'ready', category: '默认类目', indexedAt: '2026-05-09 10:25:30', slices: 89, chars: 34560, parseError: null }, + { id: 'f4', name: '化学元素表.pdf', size: '3.2MB', format: 'PDF', dataSize: '3.2MB', status: 'error', category: '教学类目', indexedAt: '-', slices: 0, chars: 0, parseError: 'PDF解析失败:文件格式损坏' }, + { id: 'f5', name: '天文知识.png', size: '5.6MB', format: 'PNG', dataSize: '5.6MB', status: 'ready', category: '默认类目', indexedAt: '2026-05-08 16:45:22', slices: 34, chars: 8900, parseError: null }, + { id: 'f6', name: '地理常识.docx', size: '2.1MB', format: 'DOCX', dataSize: '2.1MB', status: 'ready', category: '默认类目', indexedAt: '2026-05-07 11:20:15', slices: 67, chars: 23450, parseError: null }, + ]; + + const mockSlices = [ + { id: 's1', content: '光合作用是植物、藻类和某些细菌利用光能将二氧化碳和水转化为有机物并释放氧气的过程。这个过程主要发生在植物叶片的叶绿体中,是地球上最重要的生化反应之一。', source: '儿童科学百科.pdf', chars: 89, score: 0.95, sliceNo: 1, metadata: { title: '第一章 光合作用', author: '张三' } }, + { id: 's2', content: '植物通过根部的根毛吸收土壤中的水分和无机盐,这些物质通过维管束运输到叶片。叶片中的气孔吸收空气中的二氧化碳,同时释放氧气和水蒸气。', source: '儿童科学百科.pdf', chars: 76, score: 0.88, sliceNo: 2, metadata: { title: '第一章 光合作用' } }, + { id: 's3', content: '叶绿体是植物细胞中进行光合作用的主要场所,含有叶绿素等色素,赋予植物绿色的外观。叶绿体具有双层膜结构,内部有类囊体堆叠形成的基粒。', source: '儿童科学百科.pdf', chars: 68, score: 0.82, sliceNo: 3, metadata: { title: '第一章 光合作用' } }, + ]; + + const testHistory = [ + { id: 'h1', time: '2026-05-13 14:30:25', query: '光合作用是什么?', recallCount: 3, avgScore: 0.92 }, + { id: 'h2', time: '2026-05-13 10:15:42', query: '植物如何吸收水分?', recallCount: 2, avgScore: 0.85 }, + { id: 'h3', time: '2026-05-12 16:45:00', query: '什么是叶绿体?', recallCount: 4, avgScore: 0.78 }, + ]; + + const quickQuestions = [ + '什么是光合作用?', + '植物如何吸收水分?', + '叶绿体的作用是什么?', + '光合作用需要什么条件?' + ]; + + // ==================== 工具函数 ==================== + + function getFileIcon(format) { + const icons = { + PDF: '📄', DOCX: '📄', DOC: '📄', TXT: '📝', MD: '📋', + XLSX: '📊', XLS: '📊', CSV: '📊', + PNG: '🖼', JPG: '🖼', JPEG: '🖼', GIF: '🖼', BMP: '🖼', + MP4: '🎬', AVI: '🎬', MOV: '🎬', MKV: '🎬', WMV: '🎬' + }; + return icons[format] || '📁'; + } + + function getTypeIcon(type) { + const icons = { + doc_search: 'file-text', + data_query: 'table', + image_qa: 'image', + video_search: 'video' + }; + return icons[type] || 'database'; + } + + function formatTime(timeStr) { + if (!timeStr || timeStr === '-') return '-'; + return timeStr; + } + + function getStatusBadge(status) { + const map = { + ready: { label: '解析完成', cls: 'badge-success' }, + processing: { label: '解析中', cls: 'badge-warning' }, + error: { label: '解析失败', cls: 'badge-danger' }, + uploading: { label: '上传中', cls: 'badge-info' }, + }; + return map[status] || { label: status, cls: 'badge-default' }; + } + + // ==================== 渲染函数 ==================== + + function renderPage() { + container.innerHTML = ` +
+ +
+ + / + 知识库管理 + / + ${kbData.name} +
+ + +
+
+
+ + ${kbData.name} + + + ${kbData.status === 'ready' ? '已就绪' : kbData.status === 'processing' ? '处理中' : '异常'} + +
+
${kbData.description}
+
+ ID: ${kbData.id} + 类型: ${kbData.typeName} + 文档: ${kbData.docs.toLocaleString()} + 数据量: ${kbData.size} + 更新: ${formatTime(kbData.updatedAt)} +
+
+ + ${kbData.tags.map(t => `${t}`).join('')} + +
+
+
+ + + +
+
+ + +
+
+ + 文件管理 +
+
+ + 标签管理 +
+
+ + 切片管理 +
+
+ + 统计信息 +
+
+ + +
+ ${currentTab === 'files' ? renderFilesTab() : + currentTab === 'tags' ? renderTagsTab() : + currentTab === 'slices' ? renderSlicesTab() : + renderMetadataTab()} +
+
+ `; + + lucide.createIcons({ elements: [container] }); + } + + // ==================== 文件管理Tab ==================== + + function renderFilesTab() { + const readyCount = kbFiles.filter(f => f.status === 'ready').length; + const processingCount = kbFiles.filter(f => f.status === 'processing' || f.status === 'uploading').length; + const errorCount = kbFiles.filter(f => f.status === 'error').length; + + return ` + +
+
+
${kbFiles.length}
+
总文件数
+
+
+
${readyCount}
+
已解析
+
+
+
${processingCount}
+
解析中
+
+
+
${errorCount}
+
解析失败
+
+
+ + +
+
+ 已选择 ${selectedFiles.size} 个文件 +
+
+ + + + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + ${renderFileRows()} + +
+ + 文件名格式大小数据量状态所属类目切片数索引时间操作
+
+ `; + } + + function renderFileRows() { + if (kbFiles.length === 0) { + return ` + + +
+
+ +
+
暂无文件
+
点击上方「上传文件」添加文件
+
+ + + `; + } + + return kbFiles.map(file => ` + + + + + +
+ ${getFileIcon(file.format)} + ${file.name} +
+ + ${file.format} + ${file.size} + ${file.dataSize} + + + + ${getStatusBadge(file.status).label} + + ${file.status === 'error' && file.parseError ? ` +
+ ${file.parseError} +
+ ` : ''} + + ${file.category} + + ${file.slices > 0 ? `${file.slices}` : '-'} + + ${formatTime(file.indexedAt)} + +
+ + +
+ + + `).join(''); + } + + // ==================== 标签管理Tab ==================== + + function renderTagsTab() { + return ` +
+ +
+
+

知识库标签

+ +
+
+ ${kbData.tags.map((tag, idx) => ` +
+ ${tag} + + + +
+ `).join('')} +
+ +
+
+
+ + 标签字数限制:0-32 个字符,支持中英文、数字 +
+
+ + +
+

标签使用统计

+
+ ${kbData.tags.map(tag => ` +
+ ${tag} + ${Math.floor(Math.random() * 100 + 50)} 个文件 +
+ `).join('')} +
+
+
+ `; + } + + // ==================== 切片管理Tab ==================== + + function renderSlicesTab() { + return ` +
+ +
+
+ + 共 ${kbData.docs} 个切片 + +
+ +
+ + +
+ ${mockSlices.map((slice, idx) => ` +
+
+ + 切片 #${slice.sliceNo} + + 来源: ${slice.source} · ${slice.chars} 字符 + + +
+ + 相关度: ${(slice.score * 100).toFixed(0)}% + + +
+
+
${slice.content}
+ ${slice.metadata ? ` +
+ + ${Object.entries(slice.metadata).map(([k,v]) => `${k}: ${v}`).join(' | ')} +
+ ` : ''} +
+ `).join('')} +
+ + +
+
共 ${kbData.docs} 个切片
+
+ + + + + ... + + +
+
+
+ `; + } + + // ==================== 统计信息Tab ==================== + + function renderMetadataTab() { + return ` +
+ +
+
+ 切片统计 +
+
+
+ 总切片数 + ${kbData.docs.toLocaleString()} +
+
+ 总字符数 + 1,245,678 +
+
+ 平均切片长度 + 439 字符 +
+
+
+ + +
+
+ 文件格式分布 +
+
+
+ PDF + 45% +
+
+
+ DOCX + 30% +
+
+
+ XLSX + 15% +
+
+
+ 其他 + 10% +
+
+
+
+ + +
+
+ Metadata 统计 +
+
+
+
提取的标题
+
+ ${['第一章 光合作用', '第二章 呼吸作用', '第三章 物质运输', '第四章 生长素'].map(t => ` + ${t} + `).join('')} +
+
+
+
关键词
+
+ ${['光合作用', '叶绿体', '氧气', '二氧化碳', '水分'].map(k => ` + ${k} + `).join('')} +
+
+
+
+
+ `; + } + + // ==================== 全局函数 ==================== + + let __dropdownHandler = null; + + window.__switchTab = function(tab) { + currentTab = tab; + renderPage(); + }; + + window.__toggleDetailDropdown = function(btn, e) { + if (e) e.stopPropagation(); + const dropdown = btn.closest('.dropdown'); + const menu = dropdown.querySelector('.dropdown-menu'); + const isOpen = dropdown.classList.contains('open'); + + document.querySelectorAll('.dropdown').forEach(d => { + d.classList.remove('open'); + d.querySelector('.dropdown-menu').style.display = 'none'; + }); + document.removeEventListener('click', __dropdownHandler); + + if (!isOpen) { + dropdown.classList.add('open'); + menu.style.display = 'block'; + __dropdownHandler = (ev) => { + if (!dropdown.contains(ev.target)) { + dropdown.classList.remove('open'); + menu.style.display = 'none'; + document.removeEventListener('click', __dropdownHandler); + } + }; + setTimeout(() => document.addEventListener('click', __dropdownHandler), 10); + } + }; + + window.__toggleFileDropdown = function(id, btn, e) { + window.__toggleDetailDropdown(btn, e); + }; + + // 文件选择 + window.__toggleFileSelect = function(id, checked) { + if (checked) selectedFiles.add(id); + else selectedFiles.delete(id); + renderPage(); + }; + + window.__toggleSelectAllFiles = function(checked) { + if (checked) kbFiles.forEach(f => selectedFiles.add(f.id)); + else selectedFiles.clear(); + renderPage(); + }; + + window.__clearFileSelection = function() { + selectedFiles.clear(); + renderPage(); + }; + + window.__selectAllFiles = function() { + kbFiles.forEach(f => selectedFiles.add(f.id)); + renderPage(); + }; + + window.__filterFiles = function(keyword) { + const tbody = document.getElementById('fileTableBody'); + if (!tbody) return; + tbody.querySelectorAll('tr').forEach(row => { + const name = row.querySelector('td:nth-child(2)')?.textContent.toLowerCase() || ''; + row.style.display = name.includes(keyword.toLowerCase()) ? '' : 'none'; + }); + }; + + window.__filterSlices = function(keyword) { + Toast.info('搜索切片: ' + keyword); + }; + + // 文件操作 + window.__uploadFiles = function() { + Toast.info('上传文件功能'); + }; + + window.__viewMetaInfo = function(fileId) { + const file = kbFiles.find(f => f.id === fileId); + createModal({ + title: 'Meta 信息', + content: ` + + + + + + + + + + +
文件名${file?.name || '-'}
文件大小${file?.size || '-'}
数据量${file?.dataSize || '-'}
文件格式${file?.format || '-'}
切片数量${file?.slices || 0} 个
字符总数${file?.chars?.toLocaleString() || 0} 字符
状态${getStatusBadge(file?.status).label}
所属类目${file?.category || '-'}
索引时间${formatTime(file?.indexedAt)}
+ `, + confirmText: '', + cancelText: '关闭' + }); + }; + + window.__viewSlices = function(fileId) { + const file = kbFiles.find(f => f.id === fileId); + createModal({ + title: `查看切片 - ${file?.name || ''}`, + size: 'lg', + content: ` +
+
+ 切片数量: ${file?.slices || 0} + 总字符数: ${file?.chars?.toLocaleString() || 0} +
+
+
+ ${mockSlices.map((slice, idx) => ` +
+
+ + 切片 ${slice.sliceNo} + ${slice.chars} 字符 + +
+ +
+
+
${slice.content}
+
+ `).join('')} +
+ `, + confirmText: '', + cancelText: '关闭' + }); + lucide.createIcons(); + }; + + window.__reparseFile = function(fileId) { + const file = kbFiles.find(f => f.id === fileId); + createModal({ + title: '重新解析', + content: ` +

确定要重新解析文件「${file?.name}」吗?

+

重新解析将覆盖之前的解析结果。

+ `, + confirmText: '重新解析', + onConfirm: () => { + // TODO: 调用 API POST /api/v1/knowledge-bases/:id/files/:fileId/reparse + const idx = kbFiles.findIndex(f => f.id === fileId); + if (idx !== -1) { + kbFiles[idx].status = 'processing'; + renderPage(); + } + Toast.success('重新解析任务已启动'); + } + }); + }; + + window.__moveFile = function(fileId) { + Toast.info('移动文件功能'); + }; + + window.__deleteFile = function(fileId) { + const file = kbFiles.find(f => f.id === fileId); + createModal({ + title: '删除文件', + content: ` +

确定要删除文件「${file?.name}」吗?

+
+ +
此操作不可恢复,文件及其切片数据将被永久删除。
+
+ `, + confirmText: '删除', + confirmClass: 'btn-danger', + onConfirm: () => { + kbFiles = kbFiles.filter(f => f.id !== fileId); + renderPage(); + Toast.success('文件已删除'); + } + }); + lucide.createIcons(); + }; + + // 批量操作 + window.__reparseSelectedFiles = function() { + createModal({ + title: '重新解析', + content: `

确定要重新解析选中的 ${selectedFiles.size} 个文件吗?

`, + confirmText: '重新解析', + onConfirm: () => { + // TODO: 调用批量重新解析API + selectedFiles.forEach(id => { + const idx = kbFiles.findIndex(f => f.id === id); + if (idx !== -1) kbFiles[idx].status = 'processing'; + }); + selectedFiles.clear(); + renderPage(); + Toast.success('重新解析任务已启动'); + } + }); + }; + + window.__moveSelectedFiles = function() { + Toast.info('移动文件功能'); + }; + + window.__deleteSelectedFiles = function() { + createModal({ + title: '批量删除', + content: ` +

确定要删除选中的 ${selectedFiles.size} 个文件吗?

+
+ +
此操作不可恢复。
+
+ `, + confirmText: '删除', + confirmClass: 'btn-danger', + onConfirm: () => { + kbFiles = kbFiles.filter(f => !selectedFiles.has(f.id)); + selectedFiles.clear(); + renderPage(); + Toast.success('文件已删除'); + } + }); + }; + + // 切片编辑 + window.__editSlice = function(sliceId) { + const slice = mockSlices.find(s => s.id === sliceId); + createModal({ + title: '编辑切片', + size: 'lg', + content: ` +
+
+ 来源: ${slice?.source} · 切片 ${slice?.sliceNo} · ${slice?.chars} 字符 +
+ + +
+ +
+
+ `, + confirmText: '保存', + onConfirm: () => { + const newContent = document.getElementById('sliceEditor')?.value || ''; + const idx = mockSlices.findIndex(s => s.id === sliceId); + if (idx !== -1) { + mockSlices[idx].content = newContent; + mockSlices[idx].chars = newContent.length; + } + Toast.success('切片已保存'); + } + }); + + setTimeout(() => { + const editor = document.getElementById('sliceEditor'); + const counter = document.getElementById('charCount'); + if (editor && counter) { + editor.addEventListener('input', () => { + counter.textContent = editor.value.length; + }); + } + }, 100); + }; + + window.__showSliceHistory = function(sliceId) { + createModal({ + title: '切片历史版本', + content: ` +
+ ${[1,2,3].map(v => ` +
+
版本 ${v}
+
2026-05-${10-v} 14:30:25 · 张三
+
+ `).join('')} +
+ `, + confirmText: '', + cancelText: '关闭' + }); + }; + + window.__rollbackSlice = function(sliceId, version) { + Toast.success(`已回滚到版本 ${version}`); + }; + + // 标签管理 + window.__showAddTag = function() { + createModal({ + title: '新增标签', + content: ` +
+ + +
0/32
+
+
+ + 标签字数限制:0-32 个字符 +
+ `, + confirmText: '添加', + onConfirm: () => { + const name = document.getElementById('newTagName')?.value.trim(); + if (name) { + kbData.tags.push(name); + renderPage(); + Toast.success('标签已添加'); + } + } + }); + + setTimeout(() => { + const input = document.getElementById('newTagName'); + const counter = document.getElementById('tagCharCounter'); + if (input && counter) { + input.addEventListener('input', () => { + counter.textContent = `${input.value.length}/32`; + }); + } + }, 100); + }; + + window.__addNewTag = function() { + const input = document.getElementById('newTagInput'); + if (input?.value.trim()) { + kbData.tags.push(input.value.trim()); + input.value = ''; + renderPage(); + Toast.success('标签已添加'); + } + }; + + window.__removeTag = function(index) { + kbData.tags.splice(index, 1); + renderPage(); + Toast.success('标签已移除'); + }; + + window.__editTags = function() { + currentTab = 'tags'; + renderPage(); + }; + + // 命中测试 + window.__showHitTest = function() { + createModal({ + title: '命中率测试', + size: 'lg', + content: ` +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${quickQuestions.map(q => ` + ${q} + `).join('')} +
+ + +
+ + +
+ + +
搜索结果
+
+
+ +

输入问题并点击测试查看结果

+
+
+ + +
+
+
+ + + 历史召回记录 + + +
+
+ ${testHistory.map(h => ` +
+
${h.time}
+
问: ${h.query}
+
召回 ${h.recallCount} 个切片,相关度 ${(h.avgScore * 100).toFixed(0)}%
+
+ `).join('')} +
+
+
+
+ `, + confirmText: '', + cancelText: '关闭' + }); + lucide.createIcons(); + }; + + window.__runHitTest = function() { + const query = document.getElementById('testQuery')?.value.trim(); + if (!query) { + Toast.error('请输入测试问题'); + return; + } + + // TODO: 调用 API GET /api/v1/knowledge-bases/:id/hit-test + const resultsHtml = mockSlices.map(slice => ` +
+
+ + + ${slice.source} + + 相关度: ${(slice.score * 100).toFixed(0)}% +
+
+ ${slice.chars} 字符 + 切片 ${slice.sliceNo} +
+
${slice.content}
+
+ + +
+
+ `).join(''); + + document.getElementById('testResults').innerHTML = resultsHtml; + lucide.createIcons(); + Toast.success('测试完成'); + }; + + // 知识库操作 + window.__editKb = function() { + Toast.info('编辑知识库'); + }; + + window.__rebuildAllIndex = function() { + createModal({ + title: '重建全部索引', + content: ` +

确定要重建知识库「${kbData.name}」的全部索引吗?

+

重建过程中知识库可正常访问,预计需要几分钟时间。

+ `, + confirmText: '重建', + onConfirm: () => { + // TODO: 调用 API POST /api/v1/knowledge-bases/:id/rebuild + Toast.success('索引重建任务已启动'); + } + }); + }; + + window.__reparseAll = function() { + createModal({ + title: '重新解析全部', + content: ` +

确定要重新解析知识库「${kbData.name}」的全部文件吗?

+

此操作将重新解析所有文件,请耐心等待。

+ `, + confirmText: '重新解析', + onConfirm: () => { + Toast.success('重新解析任务已启动'); + } + }); + }; + + window.__exportKb = function() { + Toast.info('导出知识库'); + }; + + window.__deleteKb = function() { + createModal({ + title: '删除知识库', + content: ` +

确定要删除知识库「${kbData.name}」吗?

+
+ +
+ 此操作不可恢复 +

关联应用将无法继续使用该知识库。

+
+
+ `, + confirmText: '删除', + confirmClass: 'btn-danger', + onConfirm: () => { + // TODO: 调用 API DELETE /api/v1/knowledge-bases/:id + Toast.success('知识库已删除'); + setTimeout(() => navigateTo('knowledge-base'), 1000); + } + }); + lucide.createIcons(); + }; + + // ==================== 初始化 ==================== + + setTimeout(() => { + isLoading = false; + renderPage(); + }, 300); +}); diff --git a/scripts/knowledge-base.js b/scripts/knowledge-base.js new file mode 100644 index 0000000..e306313 --- /dev/null +++ b/scripts/knowledge-base.js @@ -0,0 +1,942 @@ +/** + * TalkingQ Platform - 知识库管理页面 + * 包含知识库列表、添加知识库弹窗、筛选、分页等功能 + * + * API预留: + * - GET /api/v1/knowledge-bases - 获取知识库列表 + * - POST /api/v1/knowledge-bases - 创建知识库 + * - DELETE /api/v1/knowledge-bases/:id - 删除知识库 + * - POST /api/v1/knowledge-bases/:id/rebuild - 重建索引 + */ + +registerPage('knowledge-base', function(container) { + // ==================== 状态管理 ==================== + let currentFilter = 'all'; + let searchText = ''; + let selectedRows = new Set(); + let currentPage = 1; + let pageSize = 10; + let sortField = 'updatedAt'; + let sortOrder = 'desc'; + let isLoading = true; + let kbData = []; + + // ==================== API预留 - 后端接口定义 ==================== + /** + * 获取知识库列表 + * GET /api/v1/knowledge-bases + * Query: { page, pageSize, filter, search, sort, order } + * Response: { list: [], total, page, pageSize } + */ + const API_GET_LIST = '/api/v1/knowledge-bases'; + + /** + * 删除知识库 + * DELETE /api/v1/knowledge-bases/:id + * Response: { success: true } + */ + const API_DELETE = '/api/v1/knowledge-bases/:id'; + + /** + * 重建索引 + * POST /api/v1/knowledge-bases/:id/rebuild + * Response: { success: true, taskId: string } + */ + const API_REBUILD = '/api/v1/knowledge-bases/:id/rebuild'; + + /** + * 批量删除 + * DELETE /api/v1/knowledge-bases/batch + * Body: { ids: string[] } + * Response: { success: true, deleted: number } + */ + const API_BATCH_DELETE = '/api/v1/knowledge-bases/batch'; + + /** + * 导出知识库 + * GET /api/v1/knowledge-bases/:id/export + * Response: 文件流 + */ + const API_EXPORT = '/api/v1/knowledge-bases/:id/export'; + + // ==================== 模拟数据 ==================== + const mockKbData = [ + { id: 'kb-001', name: '儿童科学百科', description: '包含各类儿童科学知识的百科全书', docs: 2840, size: '128MB', type: 'doc_search', typeName: '文档搜索', status: 'ready', createdAt: '2026-05-01 10:00:00', updatedAt: '2026-05-10 14:32:15', tags: ['儿童', '科学'] }, + { id: 'kb-002', name: '故事素材库 V2', description: '精选各类儿童故事素材', docs: 5200, size: '340MB', type: 'doc_search', typeName: '文档搜索', status: 'ready', createdAt: '2026-04-28 09:00:00', updatedAt: '2026-05-09 09:15:42', tags: ['故事', '儿童'] }, + { id: 'kb-003', name: '小学数学知识点', description: '小学数学知识体系整理', docs: 1560, size: '86MB', type: 'doc_search', typeName: '文档搜索', status: 'processing', createdAt: '2026-05-08 14:00:00', updatedAt: '2026-05-11 16:45:30', tags: ['数学', '教育'] }, + { id: 'kb-004', name: '英语启蒙词典', description: '儿童英语启蒙词汇', docs: 3200, size: '210MB', type: 'image_qa', typeName: '图片问答', status: 'ready', createdAt: '2026-04-20 11:00:00', updatedAt: '2026-05-08 11:20:08', tags: ['英语', '启蒙'] }, + { id: 'kb-005', name: '唐诗宋词精选', description: '经典唐诗宋词收录', docs: 890, size: '45MB', type: 'doc_search', typeName: '文档搜索', status: 'ready', createdAt: '2026-04-15 08:00:00', updatedAt: '2026-05-07 08:30:55', tags: ['诗词', '文学'] }, + { id: 'kb-006', name: '历史人物故事', description: '历史名人故事集', docs: 1200, size: '78MB', type: 'video_search', typeName: '音视频搜索', status: 'error', createdAt: '2026-04-10 16:00:00', updatedAt: '2026-05-06 14:10:22', tags: ['历史', '故事'] }, + { id: 'kb-007', name: '产品参数数据库', description: '公司产品技术参数', docs: 450, size: '32MB', type: 'data_query', typeName: '数据查询', status: 'ready', createdAt: '2026-05-05 10:00:00', updatedAt: '2026-05-11 10:00:00', tags: ['产品', '技术'] }, + { id: 'kb-008', name: '音乐教育资源库', description: '儿童音乐教育资源', docs: 560, size: '65MB', type: 'video_search', typeName: '音视频搜索', status: 'uploading', createdAt: '2026-05-12 10:00:00', updatedAt: '2026-05-12 18:00:00', tags: ['音乐', '教育'] }, + ]; + + // 初始化数据 + kbData = [...mockKbData]; + + // ==================== 工具函数 ==================== + + /** + * 获取状态标签信息 + */ + function getStatusBadge(status) { + const map = { + ready: { label: '已就绪', cls: 'ready' }, + processing: { label: '处理中', cls: 'processing' }, + uploading: { label: '上传中', cls: 'uploading' }, + error: { label: '异常', cls: 'error' }, + }; + return map[status] || { label: status, cls: 'default' }; + } + + /** + * 获取类型标签信息 + */ + function getTypeTag(type) { + const types = { + doc_search: { label: '文档搜索', cls: 'doc_search' }, + data_query: { label: '数据查询', cls: 'data_query' }, + image_qa: { label: '图片问答', cls: 'image_qa' }, + video_search: { label: '音视频搜索', cls: 'video_search' }, + }; + return types[type] || { label: type, cls: '' }; + } + + /** + * 获取类型图标 + */ + function getTypeIcon(type) { + const icons = { + doc_search: 'file-text', + data_query: 'table', + image_qa: 'image', + video_search: 'video' + }; + return icons[type] || 'database'; + } + + /** + * 格式化时间 + */ + function formatTime(timeStr) { + if (!timeStr) return '-'; + return timeStr; + } + + /** + * HTML转义 + */ + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + /** + * 生成分页按钮HTML + */ + function generatePagination(current, total) { + if (total <= 1) return ''; + + let html = ''; + const maxVisible = 5; + let start = Math.max(1, current - Math.floor(maxVisible / 2)); + let end = Math.min(total, start + maxVisible - 1); + + if (end - start < maxVisible - 1) { + start = Math.max(1, end - maxVisible + 1); + } + + if (start > 1) { + html += ``; + if (start > 2) html += `...`; + } + + for (let i = start; i <= end; i++) { + html += ``; + } + + if (end < total) { + if (end < total - 1) html += `...`; + html += ``; + } + + return html; + } + + // ==================== 数据处理 ==================== + + /** + * 筛选数据 + */ + function getFilteredData() { + let filtered = [...kbData]; + + // 状态筛选 + if (currentFilter !== 'all') { + filtered = filtered.filter(kb => kb.status === currentFilter); + } + + // 搜索筛选 + if (searchText) { + const s = searchText.toLowerCase(); + filtered = filtered.filter(kb => + kb.name.toLowerCase().includes(s) || + kb.description.toLowerCase().includes(s) || + kb.id.toLowerCase().includes(s) || + kb.tags.some(t => t.toLowerCase().includes(s)) + ); + } + + // 排序 + filtered.sort((a, b) => { + let valA = a[sortField]; + let valB = b[sortField]; + if (sortField === 'name' || sortField === 'typeName') { + return sortOrder === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA); + } + return sortOrder === 'asc' ? valA - valB : valB - valA; + }); + + return filtered; + } + + // ==================== 渲染函数 ==================== + + /** + * 渲染页面 + */ + function renderPage() { + const filteredData = getFilteredData(); + const total = filteredData.length; + const totalPages = Math.ceil(total / pageSize) || 1; + const displayData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + // 统计 + const readyCount = kbData.filter(k => k.status === 'ready').length; + const processingCount = kbData.filter(k => k.status === 'processing' || k.status === 'uploading').length; + const errorCount = kbData.filter(k => k.status === 'error').length; + + container.innerHTML = ` +
+ +
+
+ 已选择 ${selectedRows.size} 项 +
+
+ + + +
+
+ + +
+
+ +
+

知识库管理

+

管理和配置您的知识库,用于 AI 检索和问答

+
+
+
+ +
+
+ + +
+
+
+ 全部 ${kbData.length} +
+
+ 已就绪 ${readyCount} +
+
+ 处理中 ${processingCount} +
+
+ 异常 ${errorCount} +
+
+ +
+ + + ${isLoading ? ` +
+
+ 加载中... +
+ ` : ''} + + + ${isLoading ? renderSkeleton() : ''} + + + ${!isLoading ? ` +
+ + + + + + + + + + + + + + + ${displayData.length === 0 ? ` + + + + ` : displayData.map(kb => renderTableRow(kb)).join('')} + +
+ + + 知识库名称 + + + 类型 + + + 数据量 + + 状态 + 创建时间 + + + 更新时间 + + 操作
+ ${renderEmptyState()} +
+ + + ${total > 0 ? ` +
+
+ 共 ${total} 条记录,每页 ${pageSize} 条 +
+
+ + ${generatePagination(currentPage, totalPages)} + +
+
+ ` : ''} +
+ ` : ''} +
+ `; + + lucide.createIcons({ elements: [container] }); + bindEvents(); + } + + /** + * 渲染表格行 + */ + function renderTableRow(kb) { + return ` + + + + + +
+
+ +
+
+
${escapeHtml(kb.name)}
+
${escapeHtml(kb.description) || '暂无描述'}
+
+
+ + + + ${getTypeTag(kb.type).label} + + + +
+ ${kb.docs.toLocaleString()} 文档 + ${kb.size} +
+ + + + + ${getStatusBadge(kb.status).label} + + + ${formatTime(kb.createdAt)} + ${formatTime(kb.updatedAt)} + +
+ + + + +
+ + + `; + } + + /** + * 渲染空状态 + */ + function renderEmptyState() { + return ` +
+
+ +
+
暂无知识库
+
点击上方「添加知识库」开始创建您的第一个知识库
+ +
+ `; + } + + /** + * 渲染Skeleton加载状态 + */ + function renderSkeleton() { + return ` +
+ ${[1,2,3,4,5].map(() => ` +
+
+
+
+
+
+
+
+
+ `).join('')} +
+ `; + } + + // ==================== 事件绑定 ==================== + + function bindEvents() { + // 添加知识库 + container.querySelector('#addKbBtn')?.addEventListener('click', showCreateKbModal); + + // 搜索 + container.querySelector('#kbSearchInput')?.addEventListener('input', debounce((e) => { + searchText = e.target.value; + currentPage = 1; + selectedRows.clear(); + renderPage(); + }, 300)); + + // 模拟加载 + setTimeout(() => { + isLoading = false; + renderPage(); + }, 500); + } + + /** + * 防抖函数 + */ + function debounce(fn, delay) { + let timer = null; + return function(...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; + } + + // ==================== 添加知识库弹窗 ==================== + + /** + * 显示创建知识库弹窗 + */ + function showCreateKbModal() { + let searchValue = ''; + let selectedType = 'all'; + let selectedStatus = 'all'; + let filteredKbData = [...kbData]; + let selectedForAdd = new Set(); + + const renderKbList = () => { + // 应用筛选 + filteredKbData = kbData.filter(kb => { + if (selectedType !== 'all' && kb.type !== selectedType) return false; + if (selectedStatus !== 'all' && kb.status !== selectedStatus) return false; + if (searchValue) { + const matchName = kb.name.toLowerCase().includes(searchValue.toLowerCase()); + const matchTags = kb.tags.some(t => t.toLowerCase().includes(searchValue.toLowerCase())); + const matchDesc = kb.description?.toLowerCase().includes(searchValue.toLowerCase()); + if (!matchName && !matchTags && !matchDesc) return false; + } + return true; + }); + + const modalBody = document.querySelector('.kb-select-list-area'); + if (!modalBody) return; + + if (filteredKbData.length === 0) { + modalBody.innerHTML = ` +
+
+ +
+
未找到匹配的知识库
+
尝试其他搜索关键词或筛选条件
+
+ `; + } else { + modalBody.innerHTML = filteredKbData.map(kb => ` +
+
+ +
+
+
+ ${escapeHtml(kb.name)} + ${selectedForAdd.has(kb.id) ? '✓ 已选择' : ''} +
+
+ ${kb.docs.toLocaleString()} 文档 + · + ${kb.size} + · + ${getTypeTag(kb.type).label} + · + + + ${getStatusBadge(kb.status).label} + +
+
+ ${kb.tags.map(t => `${t}`).join('')} +
+
+
+ +
+
+ `).join(''); + } + + // 更新统计 + const summary = document.querySelector('.kb-select-info-summary'); + if (summary) { + summary.textContent = `共 ${filteredKbData.length} 个知识库${selectedForAdd.size > 0 ? `,已选择 ${selectedForAdd.size} 个` : ''}`; + } + + lucide.createIcons({ elements: [modalBody] }); + }; + + const modalOverlay = document.createElement('div'); + modalOverlay.className = 'modal-overlay'; + modalOverlay.innerHTML = ` + + `; + + document.body.appendChild(modalOverlay); + lucide.createIcons({ elements: [modalOverlay] }); + + const close = () => { + modalOverlay.classList.remove('open'); + setTimeout(() => modalOverlay.remove(), 200); + }; + + // 关闭事件 + modalOverlay.querySelector('.modal-close').addEventListener('click', close); + modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) close(); }); + + // 绑定弹窗内事件 + setTimeout(() => { + // 搜索 + const searchInput = document.getElementById('kbSelectSearch'); + searchInput?.addEventListener('input', (e) => { + searchValue = e.target.value; + renderKbList(); + }); + + // 类型筛选 + document.querySelectorAll('#typeFilter .kb-select-type-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#typeFilter .kb-select-type-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedType = btn.dataset.type; + renderKbList(); + }); + }); + + // 状态筛选 + document.querySelectorAll('#statusFilter .kb-select-type-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#statusFilter .kb-select-type-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedStatus = btn.dataset.status; + renderKbList(); + }); + }); + + renderKbList(); + }, 100); + + // 全局函数 + window.__closeAddKbModal = close; + + window.__toggleKbSelect = function(id, name) { + if (selectedForAdd.has(id)) { + selectedForAdd.delete(id); + } else { + selectedForAdd.add(id); + } + + // 更新确认按钮状态 + const confirmBtn = document.getElementById('confirmAddKbBtn'); + const countBadge = document.getElementById('selectedCountBadge'); + if (confirmBtn) { + confirmBtn.disabled = selectedForAdd.size === 0; + } + if (countBadge) { + countBadge.textContent = selectedForAdd.size > 0 ? `(${selectedForAdd.size})` : ''; + } + + renderKbList(); + }; + + window.__confirmAddKb = function() { + if (selectedForAdd.size === 0) { + Toast.warning('请先选择知识库'); + return; + } + + const selectedNames = Array.from(selectedForAdd).map(id => { + const kb = kbData.find(k => k.id === id); + return kb ? kb.name : id; + }); + + Toast.success(`已添加 ${selectedForAdd.size} 个知识库到当前应用`); + close(); + }; + + window.__goToCreateKb = function() { + close(); + navigateTo('kb-create'); + }; + + requestAnimationFrame(() => modalOverlay.classList.add('open')); + } + + // ==================== 全局操作函数 ==================== + + // 筛选 + window.__kbFilter = function(filter) { + currentFilter = filter; + currentPage = 1; + selectedRows.clear(); + renderPage(); + }; + + // 排序 + window.__kbSort = function(field) { + if (sortField === field) { + sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + sortField = field; + sortOrder = 'asc'; + } + renderPage(); + }; + + // 分页 + window.__kbGoPage = function(page) { + const totalPages = Math.ceil(getFilteredData().length / pageSize) || 1; + if (page >= 1 && page <= totalPages) { + currentPage = page; + renderPage(); + } + }; + + // 选择 + window.__kbToggleSelect = function(id, checked) { + if (checked) { + selectedRows.add(id); + } else { + selectedRows.delete(id); + } + renderPage(); + }; + + window.__kbSelectAll = function(checked) { + const displayData = getFilteredData().slice((currentPage - 1) * pageSize, currentPage * pageSize); + if (checked) { + displayData.forEach(kb => selectedRows.add(kb.id)); + } else { + selectedRows.clear(); + } + renderPage(); + }; + + window.__kbClearSelection = function() { + selectedRows.clear(); + renderPage(); + }; + + // 下拉菜单 + let __kbCloseDropdownHandler = null; + + window.__kbToggleDropdown = function(id, btn, e) { + if (e) e.stopPropagation(); + + const dropdown = btn.closest('.dropdown'); + const menu = dropdown.querySelector('.dropdown-menu'); + const isOpen = dropdown.classList.contains('open'); + + // 关闭所有下拉菜单 + document.querySelectorAll('.dropdown').forEach(d => { + d.classList.remove('open'); + d.querySelector('.dropdown-menu').style.display = 'none'; + }); + document.removeEventListener('click', __kbCloseDropdownHandler); + document.removeEventListener('mousedown', __kbCloseDropdownHandler); + + if (!isOpen) { + dropdown.classList.add('open'); + menu.style.display = 'block'; + + __kbCloseDropdownHandler = (ev) => { + if (!dropdown.contains(ev.target)) { + dropdown.classList.remove('open'); + menu.style.display = 'none'; + document.removeEventListener('mousedown', __kbCloseDropdownHandler); + } + }; + setTimeout(() => { + document.addEventListener('mousedown', __kbCloseDropdownHandler); + }, 10); + } + }; + + // 操作 + window.__kbDetail = function(id) { + navigateTo('kb-detail?id=' + id); + }; + + window.__kbHitTest = function(id) { + navigateTo('kb-detail?id=' + id + '&tab=hittest'); + }; + + window.__kbEdit = function(id) { + Toast.info('编辑知识库:' + id); + }; + + /** + * 删除知识库 + * API: DELETE /api/v1/knowledge-bases/:id + */ + window.__kbDelete = function(id) { + const kb = kbData.find(k => k.id === id); + createModal({ + title: '删除知识库', + content: ` +

确定要删除知识库「${kb?.name}」吗?

+
+ +
+ 此操作不可恢复 +

关联应用将无法继续使用该知识库,所有索引数据将被永久删除。

+
+
+ `, + confirmText: '删除', + confirmClass: 'btn-danger', + onConfirm: () => { + // TODO: 调用 API DELETE /api/v1/knowledge-bases/:id + const idx = kbData.findIndex(k => k.id === id); + if (idx !== -1) kbData.splice(idx, 1); + renderPage(); + Toast.success('知识库已删除'); + } + }); + lucide.createIcons(); + }; + + /** + * 重建索引 + * API: POST /api/v1/knowledge-bases/:id/rebuild + */ + window.__kbRebuildIndex = function(id) { + const kb = kbData.find(k => k.id === id); + createModal({ + title: '重建索引', + content: ` +

确定要重建知识库「${kb?.name}」的索引吗?

+

+ 重建过程中知识库可正常访问,预计需要几分钟时间。 +

+ `, + confirmText: '重建', + onConfirm: () => { + // TODO: 调用 API POST /api/v1/knowledge-bases/:id/rebuild + Toast.success('索引重建任务已启动'); + } + }); + }; + + /** + * 导出知识库 + * API: GET /api/v1/knowledge-bases/:id/export + */ + window.__kbExport = function(id) { + // TODO: 调用 API GET /api/v1/knowledge-bases/:id/export + Toast.info('正在导出知识库...'); + }; + + /** + * 批量导出 + * API: GET /api/v1/knowledge-bases/batch/export + */ + window.__kbBatchExport = function() { + // TODO: 调用 API GET /api/v1/knowledge-bases/batch/export + Toast.info(`正在导出 ${selectedRows.size} 个知识库...`); + }; + + /** + * 批量删除 + * API: DELETE /api/v1/knowledge-bases/batch + */ + window.__kbBatchDelete = function() { + createModal({ + title: '批量删除', + content: ` +

确定要删除选中的 ${selectedRows.size} 个知识库吗?

+
+ +
+ 此操作不可恢复 +

所有选中的知识库及其索引数据将被永久删除。

+
+
+ `, + confirmText: '删除', + confirmClass: 'btn-danger', + onConfirm: () => { + // TODO: 调用 API DELETE /api/v1/knowledge-bases/batch + selectedRows.forEach(id => { + const idx = kbData.findIndex(k => k.id === id); + if (idx !== -1) kbData.splice(idx, 1); + }); + selectedRows.clear(); + renderPage(); + Toast.success(`已删除 ${selectedRows.size + 1} 个知识库`); + } + }); + }; + + // 导出弹窗函数 + window.__showCreateKbModal = showCreateKbModal; + + // ==================== 初始化 ==================== + renderPage(); +}); diff --git a/scripts/login.js b/scripts/login.js new file mode 100644 index 0000000..03b360a --- /dev/null +++ b/scripts/login.js @@ -0,0 +1,188 @@ +/** + * TalkingQ Platform - Login Page + */ + +registerPage('login', function(container) { + container.innerHTML = ` + + `; + + lucide.createIcons({ elements: [container] }); + initLoginHandlers(); +}); + +function initLoginHandlers() { + const form = document.getElementById('loginForm'); + const loginBtn = document.getElementById('loginBtn'); + const passwordInput = document.getElementById('loginPassword'); + const passwordToggle = document.getElementById('passwordToggle'); + const eyeIcon = passwordToggle?.querySelector('.eye-icon'); + + // Password visibility toggle + passwordToggle?.addEventListener('click', () => { + const isPassword = passwordInput.type === 'password'; + passwordInput.type = isPassword ? 'text' : 'password'; + if (eyeIcon) { + eyeIcon.setAttribute('data-lucide', isPassword ? 'eye-off' : 'eye'); + lucide.createIcons({ elements: [passwordToggle] }); + } + }); + + // Form submission + form?.addEventListener('submit', async (e) => { + e.preventDefault(); + + const username = document.getElementById('loginUsername')?.value.trim(); + const password = document.getElementById('loginPassword')?.value; + const rememberMe = document.getElementById('rememberMe')?.checked; + + // Validation + if (!username) { + Toast.warning('请输入用户名或邮箱'); + return; + } + if (!password) { + Toast.warning('请输入密码'); + return; + } + + // Show loading + loginBtn.querySelector('.btn-text').style.display = 'none'; + loginBtn.querySelector('.btn-loading').style.display = 'flex'; + loginBtn.disabled = true; + + try { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Mock successful login + if (username && password) { + // Save to localStorage if remember me is checked + if (rememberMe) { + localStorage.setItem('talkingq_remember', username); + } + + // Trigger login success + onLoginSuccess({ + username: username, + displayName: username.split('@')[0] || username, + token: 'mock_token_' + Date.now() + }); + } else { + throw new Error('登录失败'); + } + } catch (error) { + Toast.error('登录失败,请检查用户名和密码'); + } finally { + loginBtn.querySelector('.btn-text').style.display = 'flex'; + loginBtn.querySelector('.btn-loading').style.display = 'none'; + loginBtn.disabled = false; + } + }); + + // Check for remembered username + const remembered = localStorage.getItem('talkingq_remember'); + if (remembered) { + const usernameInput = document.getElementById('loginUsername'); + if (usernameInput) usernameInput.value = remembered; + const rememberCheckbox = document.getElementById('rememberMe'); + if (rememberCheckbox) rememberCheckbox.checked = true; + } +} + +function onLoginSuccess(user) { + // Save auth state + sessionStorage.setItem('talkingq_user', JSON.stringify(user)); + sessionStorage.setItem('talkingq_logged_in', 'true'); + + Toast.success('登录成功!'); + + // Small delay to ensure toast shows before navigation + setTimeout(() => { + navigateTo('dashboard'); + }, 300); +} + +function logout() { + sessionStorage.removeItem('talkingq_user'); + sessionStorage.removeItem('talkingq_logged_in'); + Toast.info('已退出登录'); + setTimeout(() => { + navigateTo('login'); + }, 300); +} + +// Check if already logged in +// Note: checkAuth is now defined in app.js diff --git a/scripts/ota.js b/scripts/ota.js new file mode 100644 index 0000000..6a698a5 --- /dev/null +++ b/scripts/ota.js @@ -0,0 +1,1213 @@ +/** + * TalkingQ Platform - OTA Upgrade Page + * 按公司分类的OTA升级管理 + */ + +const OTA_INITIAL_FIRMWARES = { + company_a: { + latestVersion: 'v2.3.1', + versions: [ + { version: 'v2.3.1', date: '2026-05-10', size: '24.5 MB', changelog: '性能优化,修复已知问题', uploaded: false }, + { version: 'v2.3.0', date: '2026-04-28', size: '23.8 MB', changelog: '新增语音识别功能', uploaded: false }, + { version: 'v2.2.5', date: '2026-03-15', size: '22.1 MB', changelog: '稳定性修复', uploaded: false }, + ], + }, + company_b: { + latestVersion: 'v2.0.2', + versions: [ + { version: 'v2.0.2', date: '2026-05-12', size: '20.2 MB', changelog: 'Bug修复版本', uploaded: false }, + { version: 'v2.0.0', date: '2026-04-01', size: '19.8 MB', changelog: '全新界面设计', uploaded: false }, + { version: 'v1.8.2', date: '2026-02-20', size: '18.5 MB', changelog: '基础功能版本', uploaded: false }, + ], + }, + company_c: { + latestVersion: 'v1.8.2', + versions: [ + { version: 'v1.8.2', date: '2026-04-15', size: '18.5 MB', changelog: '安全补丁', uploaded: false }, + { version: 'v1.8.0', date: '2026-03-01', size: '18.2 MB', changelog: '功能更新', uploaded: false }, + { version: 'v1.7.5', date: '2026-01-10', size: '17.8 MB', changelog: '初始版本', uploaded: false }, + ], + }, +}; + +const OTA_INITIAL_TASKS = [ + { id: 'OTA-001', name: '智慧星-批量升级', companyId: 'company_a', companyName: '智慧星科技', targetVersion: 'v2.3.1', strategy: 'all', status: 'in_progress', progress: 68, total: 3200, success: 2176, failed: 12, createdAt: '2026-05-12 10:30', note: '全量灰度升级' }, + { id: 'OTA-002', name: '智慧星-指定版本推送', companyId: 'company_a', companyName: '智慧星科技', targetVersion: 'v2.3.0', strategy: 'version', strategyValue: 'v2.2.5', status: 'completed', progress: 100, total: 150, success: 148, failed: 2, createdAt: '2026-05-10 14:20', note: '修复旧版本语音识别问题' }, + { id: 'OTA-003', name: '未来玩具-全量升级', companyId: 'company_b', companyName: '未来玩具厂', targetVersion: 'v2.0.2', strategy: 'all', status: 'in_progress', progress: 35, total: 1500, success: 525, failed: 3, createdAt: '2026-05-12 09:00', note: '新版 UI 推送' }, + { id: 'OTA-004', name: '童趣-安全补丁', companyId: 'company_c', companyName: '童趣电子', targetVersion: 'v1.8.2', strategy: 'outdated', status: 'completed', progress: 100, total: 800, success: 795, failed: 5, createdAt: '2026-05-11 16:00', note: '安全补丁' }, + { id: 'OTA-005', name: '未来玩具-异常设备补发', companyId: 'company_b', companyName: '未来玩具厂', targetVersion: 'v2.0.2', strategy: 'specific', status: 'failed', progress: 42, total: 80, success: 33, failed: 47, createdAt: '2026-05-13 11:10', note: '部分设备下载固件失败,需要重试' }, +]; + +registerPage('ota', function(container) { + const currentCompanyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : (window.currentCompany || 'all'); + const currentCompany = window.getCurrentCompany ? window.getCurrentCompany() : { id: 'all', name: '全部公司' }; + const companies = (window.companies?.length ? window.companies : [ + { id: 'company_a', name: '智慧星科技', deviceCount: 3200, latestVersion: 'v2.3.1' }, + { id: 'company_b', name: '未来玩具厂', deviceCount: 1500, latestVersion: 'v2.0.2' }, + { id: 'company_c', name: '童趣电子', deviceCount: 800, latestVersion: 'v1.8.2' }, + ]).filter(company => company.id !== 'all'); + + window.__otaFirmwareStore = window.__otaFirmwareStore || JSON.parse(JSON.stringify(OTA_INITIAL_FIRMWARES)); + window.__otaTaskStore = window.__otaTaskStore || JSON.parse(JSON.stringify(OTA_INITIAL_TASKS)); + + const firmwareStore = window.__otaFirmwareStore; + const otaTasks = window.__otaTaskStore; + const currentFirmware = currentCompanyId === 'all' ? null : firmwareStore[currentCompanyId]; + const filteredTasks = currentCompanyId === 'all' + ? otaTasks + : otaTasks.filter(task => task.companyId === currentCompanyId); + + function getDisplayCompanyName(companyId, fallbackName) { + return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : fallbackName; + } + + const stats = { + total: filteredTasks.length, + inProgress: filteredTasks.filter(task => task.status === 'in_progress').length, + completed: filteredTasks.filter(task => task.status === 'completed').length, + failed: filteredTasks.filter(task => task.status === 'failed').length, + stopped: filteredTasks.filter(task => task.status === 'stopped').length, + }; + + container.innerHTML = ` + + + ${currentCompanyId === 'all' + ? `
+
+
+ +
+
+
OTA 范围
+
全部公司升级任务
+
统一查看所有客户公司的固件任务进度,选择公司可进入对应版本管理。
+
+
+
+ 任务总数${stats.total} +
+
` + : `
+
+
+ +
+
+
当前公司 OTA
+
${escapeHtml(currentCompany.name)}
+
升级任务会作用于该公司设备范围,当前最新固件为 ${escapeHtml(currentFirmware?.latestVersion || '-')}。
+
+
+
+ 最新固件${escapeHtml(currentFirmware?.latestVersion || '-')} +
+
` + } + +
+
+
+ +
+
+
${stats.total}
+
升级任务
+
+
+
+
+ +
+
+
${stats.inProgress}
+
进行中
+
+
+
+
+ +
+
+
${stats.completed}
+
已完成
+
+
+
+
+ +
+
+
${stats.failed}
+
失败
+
+
+
+
+ +
+
+
${stats.stopped}
+
已停止
+
+
+
+ + ${currentFirmware ? renderFirmwarePanel(currentCompanyId, currentCompany.name, currentFirmware) : renderFirmwareOverview()} + ${renderTaskTable(filteredTasks)} + `; + + lucide.createIcons({ elements: [container] }); + + function renderFirmwareOverview() { + return ` +
+
+ 固件概览 + 选择公司可查看全部可用固件版本 +
+
+ ${companies.map(company => { + const firmware = firmwareStore[company.id]; + return ` +
+
+ ${escapeHtml(company.name)} + +
+
最新版本
+ ${escapeHtml(firmware?.latestVersion || '-')} +
+ `; + }).join('')} +
+
+ `; + } + + function renderFirmwarePanel(companyId, companyName, firmware) { + const versions = sortFirmwareVersions(firmware.versions, firmware.latestVersion); + return ` +
+
+
+ 可用固件版本 + 最新: ${escapeHtml(firmware.latestVersion)} +
设为最新版本默认只更新版本基线;如需同步升级设备,可在确认弹窗中选择升级范围。
+
+ +
+
+ ${versions.map(version => { + const isLatest = version.version === firmware.latestVersion; + return ` +
+
+ + ${escapeHtml(version.version)}${isLatest ? '(最新)' : ''} + + ${escapeHtml(version.size)} +
+
发布日期:${escapeHtml(version.date)}
+
${escapeHtml(version.changelog || '-')}
+
+ ${version.uploaded ? `文件:${escapeHtml(version.filename || '-')}` : `${escapeHtml(companyName)} 固件`} + +
+
+ `; + }).join('')} +
+
+ `; + } + + function renderTaskTable(taskList) { + return ` +
+
+ 升级任务列表 + 共 ${taskList.length} 个任务 +
+
+ + + + + ${currentCompanyId === 'all' ? '' : ''} + + + + + + + + + + + ${taskList.length === 0 ? ` + + ` : taskList.map(renderTaskRow).join('')} + +
任务名称所属公司目标版本升级策略状态进度结果创建时间操作
暂无升级任务
+
+
+ `; + } + + function renderTaskRow(task) { + const status = getStatusMeta(task.status); + return ` + + +
${escapeHtml(task.name)}
+
ID: ${escapeHtml(task.id)}
+ + ${currentCompanyId === 'all' ? `${escapeHtml(getDisplayCompanyName(task.companyId, task.companyName))}` : ''} + ${escapeHtml(task.targetVersion)} + ${escapeHtml(formatStrategy(task))} + ${status.label} + +
+
+
+
+ ${task.progress}% +
+ + + 成功 ${task.success} + ${task.failed > 0 ? ` / 失败 ${task.failed}` : ''} + + ${escapeHtml(task.createdAt)} + + ${renderTaskActions(task)} + + + `; + } + + function renderTaskActions(task) { + const safeId = escapeInlineValue(task.id); + const actions = [``]; + + if (task.status === 'in_progress') { + actions.push(``); + actions.push(``); + } + + if (task.status === 'failed') { + actions.push(``); + } + + if (task.status === 'stopped') { + actions.push(``); + } + + if (task.status === 'completed' && task.failed > 0) { + actions.push(``); + } + + return ``; + } + + function formatStrategy(task) { + const strategyMap = { + all: '全部设备', + version: `版本 ${task.strategyValue || '-'}`, + outdated: '过期版本', + specific: '指定设备', + }; + if (task.strategy === 'specific' && task.deviceIds?.length) { + return `指定设备(${task.deviceIds.length} 台)`; + } + return strategyMap[task.strategy] || task.strategy; + } + + window.switchOTACompany = function(companyId) { + if (window.switchCompany) { + window.switchCompany(companyId); + return; + } + window.currentCompany = companyId; + navigateTo('ota'); + }; + + window.openCreateOTATask = function() { + const defaultCompanyId = currentCompanyId === 'all' ? companies[0]?.id : currentCompanyId; + const companyField = currentCompanyId === 'all' + ? ` + + ` + : ` + + + `; + + const modal = createModal({ + title: '创建升级任务', + size: 'modal-lg', + content: ` +
+
+ + ${companyField} +
+
+ + +
+
+
+
+ + +
当前最新版本会优先展示;设为最新版本不会自动升级设备。
+
+
+ + +
+
+ +
+ + +
+
+
+ 预计目标设备 + - +
+
+
+ + 接口:POST /api/v1/ota/tasks +
+ `, + onConfirm: async () => createOTATaskFromModal(), + confirmText: '创建任务', + }); + + const companySelect = modal.overlay.querySelector('#otaCompanySelect'); + const strategySelect = modal.overlay.querySelector('#otaStrategy'); + const deviceIds = modal.overlay.querySelector('#deviceIds'); + + const refreshForm = () => { + const companyId = companySelect?.value || defaultCompanyId; + const strategy = strategySelect?.value || 'all'; + const firmware = firmwareStore[companyId]; + const targetSelect = modal.overlay.querySelector('#otaTargetVersion'); + const deviceGroup = modal.overlay.querySelector('#deviceIdGroup'); + const preview = modal.overlay.querySelector('#otaTargetPreview'); + + if (targetSelect) { + targetSelect.innerHTML = firmware?.versions?.length + ? sortFirmwareVersions(firmware.versions, firmware.latestVersion).map(item => ` + + `).join('') + : ''; + } + + if (deviceGroup) deviceGroup.style.display = strategy === 'specific' ? '' : 'none'; + if (preview) { + const ids = parseDeviceIds(deviceIds?.value || ''); + preview.textContent = `${estimateTargetCount(companyId, strategy, ids)} 台`; + } + }; + + companySelect?.addEventListener('change', refreshForm); + strategySelect?.addEventListener('change', refreshForm); + deviceIds?.addEventListener('input', refreshForm); + refreshForm(); + lucide.createIcons({ elements: [modal.overlay] }); + }; + + async function createOTATaskFromModal() { + const name = document.getElementById('otaTaskName')?.value.trim(); + const companyId = document.getElementById('otaCompanySelect')?.value; + const version = document.getElementById('otaTargetVersion')?.value; + const strategy = document.getElementById('otaStrategy')?.value || 'all'; + const deviceIds = parseDeviceIds(document.getElementById('deviceIds')?.value || ''); + const note = document.getElementById('otaTaskNote')?.value.trim() || ''; + const company = getCompanyInfo(companyId); + const targetCount = estimateTargetCount(companyId, strategy, deviceIds); + + if (!name || !companyId || !version) { + Toast.warning('请填写必填项'); + return false; + } + + if (strategy === 'specific' && deviceIds.length === 0) { + Toast.warning('请输入 device_id'); + return false; + } + + if (strategy === 'specific' && deviceIds.some(id => !isDeviceId(id))) { + Toast.warning('device_id 需为 UUID 格式'); + return false; + } + + if (!firmwareStore[companyId]?.versions?.some(item => item.version === version)) { + Toast.warning('目标固件版本不存在'); + return false; + } + + const newTask = { + id: generateTaskId(), + name, + companyId, + companyName: company.name, + targetVersion: version, + strategy, + status: 'in_progress', + progress: 0, + total: targetCount, + success: 0, + failed: 0, + createdAt: formatDateTime(new Date()), + deviceIds, + note, + }; + + try { + await API.ota.create(newTask); + } catch (error) { + Toast.error('升级任务创建失败,请稍后重试'); + return false; + } + + otaTasks.unshift(newTask); + Toast.success('升级任务创建成功'); + navigateTo('ota'); + } + + window.openUploadFirmware = function(preselectedCompanyId) { + const defaultCompanyId = preselectedCompanyId || (currentCompanyId === 'all' ? companies[0]?.id : currentCompanyId); + const companyField = currentCompanyId === 'all' && !preselectedCompanyId + ? ` + + ` + : ` + + + `; + + const modal = createModal({ + title: '上传新固件', + size: 'modal-lg', + content: ` +
+
+ + ${companyField} +
+
+ + +
+
+
+ +
+ +
点击选择文件或拖拽到此处
+
支持 .bin, .hex, .zip 格式,最大 100MB
+
+
+ +
+
+ + +
+
+ + 接口:POST /api/v1/ota/firmwares +
+ `, + onConfirm: async () => uploadFirmwareFromModal(), + confirmText: '上传', + }); + + const fileInput = modal.overlay.querySelector('#fwFile'); + const dropzone = modal.overlay.querySelector('#fwDropzone'); + const fileName = modal.overlay.querySelector('#fwFileName'); + const showFile = () => { + const file = fileInput?.files?.[0]; + if (fileName) fileName.textContent = file ? `已选择:${file.name}(${formatFileSize(file.size)})` : ''; + }; + + dropzone?.addEventListener('click', () => fileInput?.click()); + fileInput?.addEventListener('change', showFile); + dropzone?.addEventListener('dragover', event => { + event.preventDefault(); + dropzone.style.borderColor = 'var(--primary)'; + }); + dropzone?.addEventListener('dragleave', () => { + dropzone.style.borderColor = 'var(--border)'; + }); + dropzone?.addEventListener('drop', event => { + event.preventDefault(); + dropzone.style.borderColor = 'var(--border)'; + if (event.dataTransfer?.files?.[0] && fileInput) { + fileInput.files = event.dataTransfer.files; + showFile(); + } + }); + + lucide.createIcons({ elements: [modal.overlay] }); + }; + + async function uploadFirmwareFromModal() { + const companyId = document.getElementById('fwCompany')?.value; + const version = document.getElementById('fwVersion')?.value.trim(); + const file = document.getElementById('fwFile')?.files?.[0]; + const changelog = document.getElementById('fwChangelog')?.value.trim() || '新固件版本'; + + if (!companyId || !version) { + Toast.warning('请填写固件版本和所属公司'); + return false; + } + + if (!/^v?\d+\.\d+\.\d+([-.][A-Za-z0-9]+)?$/.test(version)) { + Toast.warning('固件版本格式应类似 v2.3.2'); + return false; + } + + const normalizedVersion = version.startsWith('v') ? version : `v${version}`; + + if (!file) { + Toast.warning('请选择固件文件'); + return false; + } + + if (!/\.(bin|hex|zip)$/i.test(file.name)) { + Toast.warning('固件文件仅支持 .bin、.hex、.zip 格式'); + return false; + } + + if (file.size > 100 * 1024 * 1024) { + Toast.warning('固件文件不能超过 100MB'); + return false; + } + + if (firmwareStore[companyId]?.versions?.some(item => item.version.toLowerCase() === normalizedVersion.toLowerCase())) { + Toast.warning('该固件版本已存在'); + return false; + } + + const newFirmware = { + version: normalizedVersion, + date: formatDateOnly(new Date()), + size: formatFileSize(file.size), + changelog, + uploaded: true, + filename: file.name, + }; + + try { + await API.ota.uploadFirmware(file, { companyId, version: normalizedVersion, changelog }); + } catch (error) { + Toast.error('固件上传失败,请稍后重试'); + return false; + } + + firmwareStore[companyId] = firmwareStore[companyId] || { latestVersion: normalizedVersion, versions: [] }; + firmwareStore[companyId].versions.unshift(newFirmware); + firmwareStore[companyId].latestVersion = normalizedVersion; + updateCompanyLatestVersion(companyId, normalizedVersion); + Toast.success(`固件 ${escapeHtml(normalizedVersion)} 上传成功`); + + if (currentCompanyId === 'all' && window.switchCompany) { + window.switchCompany(companyId); + } else { + if (currentCompanyId === 'all') window.currentCompany = companyId; + navigateTo('ota'); + } + } + + window.setLatestFirmware = function(companyId, version) { + const firmware = firmwareStore[companyId]; + const target = firmware?.versions?.find(item => item.version === version); + if (!target) { + Toast.warning('固件版本不存在'); + return; + } + + if (firmware.latestVersion === version) { + Toast.info(`${escapeHtml(version)} 已经是最新版本`); + return; + } + + const company = getCompanyInfo(companyId); + const currentLatest = firmware.latestVersion || '-'; + const defaultTaskName = `${company.name}-${version}-版本升级`; + const modal = createModal({ + title: '设为最新版本', + size: 'modal-lg', + content: ` +
+
+
+ +
+
+
将 ${escapeHtml(version)} 设为 ${escapeHtml(company.name)} 的最新版本
+
此操作会更新该公司的最新固件基线;是否同时升级设备由下方升级范围决定。
+
+
+ +
+
+ 当前最新 + ${escapeHtml(currentLatest)} +
+
+ +
+
+ 调整为 + ${escapeHtml(version)} +
+
+ +
+
是否同时更新设备
+
+ + + + +
+
+ + + +
+ + 接口:仅设为最新 POST /api/v1/ota/firmwares/{id}/set-latest;同时升级 POST /api/v1/ota/firmwares/{id}/set-latest-and-upgrade +
+
+ `, + confirmText: '确认操作', + onConfirm: async () => { + const mode = modal.overlay.querySelector('input[name="latestUpgradeMode"]:checked')?.value || 'none'; + const shouldCreateTask = mode !== 'none'; + const deviceIds = parseDeviceIds(modal.overlay.querySelector('#latestSpecificDeviceIds')?.value || ''); + const taskName = modal.overlay.querySelector('#latestUpgradeTaskName')?.value.trim() || defaultTaskName; + const note = modal.overlay.querySelector('#latestUpgradeNote')?.value.trim() + || `由设为最新版本操作创建,原最新版本 ${currentLatest}`; + const targetCount = estimateTargetCount(companyId, mode, deviceIds); + + if (mode === 'specific' && deviceIds.length === 0) { + Toast.warning('请输入需要升级的 device_id'); + return false; + } + + if (mode === 'specific' && deviceIds.some(id => !isDeviceId(id))) { + Toast.warning('device_id 需为 UUID 格式'); + return false; + } + + if (shouldCreateTask && !taskName) { + Toast.warning('请填写升级任务名称'); + return false; + } + + const upgradeTask = shouldCreateTask ? { + id: generateTaskId(), + name: taskName, + companyId, + companyName: company.name, + targetVersion: version, + strategy: mode, + status: 'in_progress', + progress: 0, + total: targetCount, + success: 0, + failed: 0, + createdAt: formatDateTime(new Date()), + deviceIds, + note, + source: 'set_latest_version', + previousLatestVersion: currentLatest, + } : null; + + try { + if (shouldCreateTask) { + await API.ota.setLatestAndCreateTask(`${companyId}:${version}`, { + companyId, + version, + previousLatestVersion: currentLatest, + upgradeTask, + }); + } else { + await API.ota.setLatestFirmware(`${companyId}:${version}`, { + companyId, + version, + previousLatestVersion: currentLatest, + }); + } + } catch (error) { + Toast.error(shouldCreateTask ? '最新版本设置或升级任务创建失败,请稍后重试' : '最新版本设置失败,请稍后重试'); + return false; + } + + firmware.latestVersion = version; + updateCompanyLatestVersion(companyId, version); + if (upgradeTask) { + otaTasks.unshift(upgradeTask); + Toast.success(`${escapeHtml(version)} 已设为最新版本,并已创建升级任务`); + } else { + Toast.success(`${escapeHtml(version)} 已设为最新版本`); + } + navigateTo('ota'); + }, + }); + + const refreshLatestUpgradeForm = () => { + const mode = modal.overlay.querySelector('input[name="latestUpgradeMode"]:checked')?.value || 'none'; + const shouldCreateTask = mode !== 'none'; + const config = modal.overlay.querySelector('#latestUpgradeConfig'); + const specificWrap = modal.overlay.querySelector('#latestSpecificDevicesWrap'); + const preview = modal.overlay.querySelector('#latestUpgradePreview'); + const ids = parseDeviceIds(modal.overlay.querySelector('#latestSpecificDeviceIds')?.value || ''); + + modal.overlay.querySelectorAll('.ota-upgrade-option').forEach(option => { + option.classList.toggle('active', option.dataset.upgradeOption === mode); + }); + if (config) config.style.display = shouldCreateTask ? '' : 'none'; + if (specificWrap) specificWrap.style.display = mode === 'specific' ? '' : 'none'; + if (preview) { + preview.textContent = shouldCreateTask + ? `${estimateTargetCount(companyId, mode, ids)} 台设备` + : '不创建升级任务'; + } + }; + + modal.overlay.querySelectorAll('input[name="latestUpgradeMode"]').forEach(input => { + input.addEventListener('change', refreshLatestUpgradeForm); + }); + modal.overlay.querySelector('#latestSpecificDeviceIds')?.addEventListener('input', refreshLatestUpgradeForm); + refreshLatestUpgradeForm(); + lucide.createIcons({ elements: [modal.overlay] }); + }; + + window.deleteFirmware = function(companyId, version) { + const firmware = firmwareStore[companyId]; + if (!firmware) return; + const target = firmware.versions.find(item => item.version === version); + if (!target) return; + + if (firmware.latestVersion === version) { + Toast.warning('最新固件不能删除,请先设置其他版本为最新'); + return; + } + + createModal({ + title: '确认删除固件', + content: ` +
+ +

确认删除固件 ${escapeHtml(version)}?

+

删除后将不能再创建指向该版本的新升级任务

+
+ `, + onConfirm: async () => { + try { + await API.ota.deleteFirmware(`${companyId}:${version}`); + } catch (error) { + Toast.error('固件删除失败,请稍后重试'); + return false; + } + firmware.versions = firmware.versions.filter(item => item.version !== version); + Toast.success(`固件 ${escapeHtml(version)} 已删除`); + navigateTo('ota'); + }, + confirmText: '确认删除', + }); + lucide.createIcons(); + }; + + window.refreshOTATask = async function(taskId) { + const task = otaTasks.find(item => item.id === taskId); + if (!task) { + Toast.warning('未找到该任务'); + return; + } + if (task.status !== 'in_progress') { + Toast.info('该任务已结束'); + return; + } + + try { + await API.ota.getProgress(taskId); + } catch (error) { + Toast.error('进度刷新失败,请稍后重试'); + return; + } + + const nextProgress = Math.min(100, task.progress + Math.max(8, Math.round((100 - task.progress) * 0.22))); + const processed = Math.round(task.total * nextProgress / 100); + const newFailures = nextProgress >= 100 ? 0 : Math.floor(Math.random() * 3); + task.progress = nextProgress; + task.failed = Math.min((task.failed || 0) + newFailures, processed); + task.success = Math.max(0, Math.min(task.total, processed - task.failed)); + if (nextProgress >= 100) { + task.status = 'completed'; + task.success = Math.max(0, task.total - task.failed); + } + Toast.success(task.status === 'completed' ? '升级任务已完成' : '任务进度已刷新'); + navigateTo('ota'); + }; + + window.retryOTATask = async function(taskId, mode) { + const task = otaTasks.find(item => item.id === taskId); + if (!task) { + Toast.warning('未找到该任务'); + return; + } + + if (mode === 'failed-devices') { + if (task.failed <= 0) { + Toast.info('该任务没有失败设备'); + return; + } + + const retryTask = { + id: generateTaskId(), + name: `${task.name}-失败设备重试`, + companyId: task.companyId, + companyName: getDisplayCompanyName(task.companyId, task.companyName), + targetVersion: task.targetVersion, + strategy: 'specific', + status: 'in_progress', + progress: 0, + total: task.failed, + success: 0, + failed: 0, + createdAt: formatDateTime(new Date()), + deviceIds: [], + note: `来自 ${task.id} 的失败设备重试`, + retryOf: task.id, + }; + + try { + await API.ota.create(retryTask); + } catch (error) { + Toast.error('失败设备重试任务创建失败,请稍后重试'); + return; + } + + otaTasks.unshift(retryTask); + Toast.success('失败设备重试任务已创建'); + navigateTo('ota'); + return; + } + + try { + if (mode === 'resume' && API.ota.resume) { + await API.ota.resume(taskId); + } else { + await API.ota.create({ retryOf: taskId, mode }); + } + } catch (error) { + Toast.error('任务重试失败,请稍后重试'); + return; + } + + task.status = 'in_progress'; + if (mode === 'failed') { + task.progress = 0; + task.success = 0; + task.failed = 0; + task.createdAt = formatDateTime(new Date()); + task.note = `${task.note || ''} 已重新发起失败任务重试`.trim(); + } + + Toast.success(mode === 'resume' ? '升级任务已继续' : '升级任务已重新发起'); + navigateTo('ota'); + }; + + window.viewOTATask = function(taskId) { + const task = otaTasks.find(item => item.id === taskId); + if (!task) { + Toast.warning('未找到该任务'); + return; + } + + const status = getStatusMeta(task.status); + createModal({ + title: `任务详情 - ${escapeHtml(task.name)}`, + size: 'modal-lg', + content: ` +
+
+ +
${escapeHtml(task.id)}
+
+
+ +
${escapeHtml(getDisplayCompanyName(task.companyId, task.companyName))}
+
+
+ +
${escapeHtml(task.targetVersion)}
+
+
+ +
${escapeHtml(formatStrategy(task))}
+
+
+ +
${escapeHtml(task.createdAt)}
+
+
+ +
${status.label}
+
+
+
+
+ 升级进度 + ${task.progress}% +
+
+
+
+
+
+
+
${task.total}
+
目标设备
+
+
+
${task.success}
+
升级成功
+
+
+
${task.failed}
+
升级失败
+
+
+
+
任务日志
+ ${renderTaskLogs(task)} +
+ `, + confirmText: '关闭', + showCancel: false, + }); + lucide.createIcons(); + }; + + window.stopOTATask = function(taskId) { + const task = otaTasks.find(item => item.id === taskId); + if (!task) { + Toast.warning('未找到该任务'); + return; + } + + createModal({ + title: '确认停止', + content: ` +
+ +

确认停止升级任务 ${escapeHtml(task.name)}?

+

已升级的设备不受影响,未升级的设备将不再升级

+
+ `, + onConfirm: async () => { + try { + await API.ota.cancel(taskId); + } catch (error) { + Toast.error('停止任务失败,请稍后重试'); + return false; + } + task.status = 'stopped'; + task.progress = Math.max(task.progress, Math.round(task.success / Math.max(task.total, 1) * 100)); + Toast.success('升级任务已停止'); + navigateTo('ota'); + }, + confirmText: '确认停止', + }); + lucide.createIcons(); + }; + + function renderTaskLogs(task) { + const rows = [ + `${escapeHtml(task.createdAt)} 创建任务,目标版本 ${escapeHtml(task.targetVersion)}`, + `${escapeHtml(task.createdAt)} 下发升级策略:${escapeHtml(formatStrategy(task))}`, + ]; + if (task.source === 'set_latest_version') { + rows.push(`任务来源:设为最新版本操作${task.previousLatestVersion ? `,原最新版本 ${escapeHtml(task.previousLatestVersion)}` : ''}`); + } + if (task.note) rows.push(`升级说明:${escapeHtml(task.note)}`); + if (task.progress > 0) rows.push(`当前已处理 ${task.success + task.failed}/${task.total} 台设备`); + if (task.status === 'completed') rows.push('任务已完成,等待设备心跳确认最终版本'); + if (task.status === 'stopped') rows.push('任务已停止,未升级设备不会继续推送'); + if (task.failed > 0) rows.push(`发现 ${task.failed} 台设备升级失败,可在后端接入后查看失败明细`); + return rows.map(row => `
${row}
`).join(''); + } + + function getStatusMeta(status) { + const map = { + in_progress: { label: '进行中', className: 'badge-warning' }, + completed: { label: '已完成', className: 'badge-success' }, + failed: { label: '失败', className: 'badge-danger' }, + stopped: { label: '已停止', className: 'badge-default' }, + }; + return map[status] || { label: status || '未知', className: 'badge-default' }; + } + + function sortFirmwareVersions(versions, latestVersion) { + return [...(versions || [])].sort((a, b) => { + if (a.version === latestVersion) return -1; + if (b.version === latestVersion) return 1; + return new Date(b.date) - new Date(a.date); + }); + } + + function parseDeviceIds(value) { + return value + .split(/[\n,,\s]+/) + .map(item => item.trim().toLowerCase()) + .filter(Boolean); + } + + function isDeviceId(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim()); + } + + function estimateTargetCount(companyId, strategy, deviceIds) { + const deviceCount = getCompanyInfo(companyId).deviceCount || 100; + if (strategy === 'specific') return deviceIds.length; + if (strategy === 'outdated') return Math.max(1, Math.round(deviceCount * 0.28)); + return deviceCount; + } + + function getCompanyInfo(companyId) { + return companies.find(company => company.id === companyId) || { id: companyId, name: companyId || '未知公司', deviceCount: 100 }; + } + + function updateCompanyLatestVersion(companyId, version) { + const company = window.companies?.find(item => item.id === companyId); + if (company) company.latestVersion = version; + } + + function generateTaskId() { + const max = otaTasks + .map(task => parseInt(String(task.id).replace('OTA-', ''), 10)) + .filter(Number.isFinite) + .reduce((value, next) => Math.max(value, next), 0); + return `OTA-${String(max + 1).padStart(3, '0')}`; + } + + function formatDateOnly(date) { + const pad = value => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + + function formatDateTime(date) { + const pad = value => String(value).padStart(2, '0'); + return `${formatDateOnly(date)} ${pad(date.getHours())}:${pad(date.getMinutes())}`; + } + + function formatFileSize(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB'; + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${Math.ceil(bytes / 1024)} KB`; + } + + function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); + } + + function escapeInlineValue(value) { + return escapeHtml(String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r?\n/g, ' ')); + } +}); diff --git a/scripts/privacy.js b/scripts/privacy.js new file mode 100644 index 0000000..57e5094 --- /dev/null +++ b/scripts/privacy.js @@ -0,0 +1,313 @@ +/** + * TalkingQ Platform - Privacy (PII) Page + */ + +registerPage('privacy', function(container) { + container.innerHTML = ` + + +
+
PII 脱敏规则
+
GDPR/COPPA
+
数据保留
+
+ + +
+
+
+
+
PII 自动脱敏配置
+
自动识别并脱敏对话中的个人敏感信息
+
+
+ 全局启用 + +
+
+
+ ${[ + { name: '姓名', example: '张三 → ***', enabled: true }, + { name: '手机号', example: '138****8888', enabled: true }, + { name: 'IP 地址', example: '192.168.x.x', enabled: true }, + { name: '身份证号', example: '330102****1234', enabled: true }, + { name: '家庭住址', example: '北京市朝阳区*** → [地址已脱敏]', enabled: true }, + { name: '银行卡号', example: '6222****1234', enabled: false }, + ].map(r => ` +
+
+
${r.name}
+
${r.example}
+
+ +
+ `).join('')} +
+
+ +
+ +
PII 规则接口:GET /api/v1/privacy/pii-rules · PUT /api/v1/privacy/pii-rules/{id}。 + 脱敏事件通过 pii_detected / pii_masked 事件上报。
+
+
+ + +
+
+
+
COPPA 合规(儿童隐私保护)
+ ${[ + { label: '用户年龄验证', enabled: true }, + { label: '家长知情同意收集', enabled: true }, + { label: '禁止收集13岁以下精确位置', enabled: true }, + { label: '行为追踪限制', enabled: true }, + { label: '数据最小化原则', enabled: true }, + ].map(i => ` +
+ ${i.label} + +
+ `).join('')} +
+ ✅ COPPA 合规状态:已合规 +
+
+
+
GDPR 数据权利
+ ${[ + { label: '知情权(提供隐私政策)', enabled: true }, + { label: '访问权(数据导出)', enabled: true }, + { label: '更正权', enabled: true }, + { label: '删除权(被遗忘权)', enabled: true }, + { label: '数据可携带权', enabled: false }, + ].map(i => ` +
+ ${i.label} + +
+ `).join('')} +
+ ⚠️ 数据可携带权尚未完整实现 +
+
+
+
+ + +
+
+
数据保留策略
+ ${[ + { type: '对话记录 (Sessions)', period: '180', unit: '天' }, + { type: '设备日志 (Device Logs)', period: '90', unit: '天' }, + { type: 'Bad Case 数据', period: '365', unit: '天' }, + { type: 'OTA 升级记录', period: '365', unit: '天' }, + { type: '审计日志 (Audit Logs)', period: '730', unit: '天' }, + { type: 'PII 脱敏日志', period: '30', unit: '天' }, + ].map(p => ` +
+ ${p.type} + + ${p.unit} +
+ `).join('')} + +
+
+ `; + + lucide.createIcons({ elements: [container] }); + + container.querySelectorAll('#privacyTabs .tab-item').forEach(tab => { + tab.addEventListener('click', () => { + container.querySelectorAll('#privacyTabs .tab-item').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + container.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + container.querySelector(`#tab-${tab.dataset.tab}`)?.classList.add('active'); + }); + }); +}); + +/** + * TalkingQ Platform - Settings Page + */ +registerPage('settings', function(container) { + container.innerHTML = ` + + +
+
租户信息
+
API Keys
+
通知配置
+
集成
+
+ +
+
+
租户基本信息
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+
API Keys
+ +
+
+ + + + ${[ + { name: 'Backend Service Key', key: 'tq_live_****a3f2', scope: '全部权限', created: '2026-01-01', used: '2分钟前' }, + { name: 'Analytics Read-only', key: 'tq_ro_****c8e1', scope: '只读', created: '2026-03-15', used: '今天' }, + ].map(k => ` + + + + + + + + + `).join('')} + +
名称Key(部分)权限创建时间最后使用操作
${k.name}${k.key}${k.scope}${k.created}${k.used} + + +
+
+
+ + API Key 接口:GET /api/v1/settings/api-keys · POST /api/v1/settings/api-keys +
+
+
+ +
+
+
通知配置
+ ${[ + { label: '设备离线告警', enabled: true }, + { label: 'OTA 升级失败告警', enabled: true }, + { label: 'Bad Case 严重问题告警', enabled: true }, + { label: 'API 超时告警', enabled: false }, + { label: '每日数据报告', enabled: true }, + ].map(n => ` +
+ ${n.label} + +
+ `).join('')} +
+ + +
+ +
+
+ +
+
+ ${[ + { name: '腾讯云 ASR', icon: '🔵', status: 'connected' }, + { name: '腾讯云 TTS', icon: '🔵', status: 'connected' }, + { name: 'OpenAI', icon: '⚫', status: 'disconnected' }, + { name: '阿里云 OSS', icon: '🟠', status: 'connected' }, + { name: 'Kafka', icon: '🟡', status: 'connected' }, + { name: 'ClickHouse', icon: '🔴', status: 'disconnected' }, + ].map(i => ` +
+ ${i.icon} +
+
${i.name}
+ ${i.status === 'connected' ? '已连接' : '未连接'} +
+ +
+ `).join('')} +
+
+ `; + + lucide.createIcons({ elements: [container] }); + + container.querySelectorAll('#settingsTabs .tab-item').forEach(tab => { + tab.addEventListener('click', () => { + container.querySelectorAll('#settingsTabs .tab-item').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + container.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + container.querySelector(`#stab-${tab.dataset.tab}`)?.classList.add('active'); + }); + }); + + container.querySelector('#createKeyBtn')?.addEventListener('click', () => { + createModal({ + title: '创建 API Key', + content: ` +
+ + +
+
+ + +
`, + onConfirm: async () => { + await API.settings.createApiKey({ name: '新 API Key' }); + Toast.success('API Key 创建成功,请妥善保存'); + }, + confirmText: '创建', + }); + }); +}); diff --git a/scripts/prompt-manage.js b/scripts/prompt-manage.js new file mode 100644 index 0000000..600f56a --- /dev/null +++ b/scripts/prompt-manage.js @@ -0,0 +1,119 @@ +/** + * TalkingQ Platform - Prompt Management Page + */ + +registerPage('prompt-manage', function(container) { + const prompts = [ + { id: 'prompt-001', name: '故事机器人 主提示词 v2.3', agent: '小Q故事机器人', version: 'v2.3', status: 'active', score: 8.2, sessions: 38420, lastUpdate: '2026-05-10' }, + { id: 'prompt-002', name: '故事机器人 AB测试变体', agent: '小Q故事机器人', version: 'v2.3-b', status: 'ab-testing', score: 8.5, sessions: 2100, lastUpdate: '2026-05-08' }, + { id: 'prompt-003', name: '英语助手 v1.2', agent: '英语学习助手', version: 'v1.2', status: 'active', score: 7.8, sessions: 12300, lastUpdate: '2026-04-28' }, + { id: 'prompt-004', name: '科学探索 Beta', agent: '科学百科探索者', version: 'v0.8', status: 'draft', score: null, sessions: 0, lastUpdate: '2026-05-12' }, + ]; + + container.innerHTML = ` + + +
+
+ + +
+ + + +
+ +
+
+ + + + + + ${prompts.map(p => ` + + + + + + + + + + + `).join('')} + +
提示词名称关联应用版本状态平均评分覆盖对话数最后更新操作
+
${p.name}
+
${p.id}
+
${p.agent}${p.version} + ${p.status === 'active' ? '上线中' : p.status === 'ab-testing' ? 'AB测试中' : '草稿'} + + ${p.score ? `${p.score}/10` : '--'} + ${p.sessions > 0 ? p.sessions.toLocaleString() : '--'}${p.lastUpdate} +
+ + + ${p.status === 'draft' ? `` : ''} + ${p.status === 'active' ? `` : ''} +
+
+
+
+ +
+ + 提示词接口:GET /api/v1/prompts · POST /api/v1/prompts/{id}/publish · POST /api/v1/prompts/ab-test +
+ `; + + lucide.createIcons({ elements: [container] }); + + container.querySelector('#newPromptBtn')?.addEventListener('click', () => { + createModal({ + title: '新建提示词', + content: ` +
+ + +
+
+ + +
+
+ + +
`, + onConfirm: async () => { + const name = document.getElementById('newPromptName')?.value.trim(); + const content = document.getElementById('newPromptContent')?.value.trim(); + if (!name || !content) { + Toast.warning('请填写提示词名称和内容'); + return false; + } + await API.prompts.create({ name, content }); + Toast.success('提示词已创建(草稿状态)'); + }, + confirmText: '创建', + }); + }); +}); diff --git a/scripts/rbac.js b/scripts/rbac.js new file mode 100644 index 0000000..35a6cd9 --- /dev/null +++ b/scripts/rbac.js @@ -0,0 +1,663 @@ +/** + * TalkingQ Platform - RBAC Permission Management Page + * 参考阿里云百炼控制台风格 + * 按公司分类的权限管理 + */ + +registerPage('rbac', function(container) { + const USERS_STORAGE_KEY = 'talkingq_rbac_users_v1'; + const DEFAULT_ROLE_COMPANIES = ['company_a', 'company_b', 'company_c']; + + const DEFAULT_USERS = [ + { id: 'u001', displayName: '张明华', loginName: 'zhangmh@talkingq.com', status: 'active', lastLogin: '2026-05-12 14:10', role: 'admin', remark: '超级管理员' }, + { id: 'u002', displayName: '李思远', loginName: 'lisiy@talkingq.com', status: 'active', lastLogin: '2026-05-12 11:30', role: 'worker', remark: '运营人员' }, + { id: 'u003', displayName: '王晓燕', loginName: 'wangxy@partner.com', status: 'active', lastLogin: '2026-05-11 09:00', role: 'client', companyId: 'company_a', allowedCompanies: ['company_a'], remark: '智慧星科技-客户' }, + { id: 'u004', displayName: '刘亚楠', loginName: 'liuyn@partner.com', status: 'inactive', lastLogin: '2026-04-28 16:00', role: 'client', companyId: 'company_b', allowedCompanies: ['company_b'], remark: '未来玩具厂-客户' }, + { id: 'u005', displayName: '陈志强', loginName: 'chenzq@talkingq.com', status: 'active', lastLogin: '2026-05-10 09:15', role: 'worker', remark: '开发人员' }, + ]; + + function cloneUsers(list) { + return list.map(item => ({ ...item, allowedCompanies: Array.isArray(item.allowedCompanies) ? [...item.allowedCompanies] : item.allowedCompanies })); + } + + function loadUsers() { + try { + localStorage.removeItem(USERS_STORAGE_KEY); + if (Array.isArray(window.__rbacUsers) && window.__rbacUsers.length) { + return window.__rbacUsers.map((user, index) => normalizeUser(user, index)); + } + } catch (error) { + console.warn('Failed to clear legacy RBAC user cache', error); + } + return cloneUsers(DEFAULT_USERS).map((user, index) => normalizeUser(user, index)); + } + + function saveUsers(users) { + window.__rbacUsers = users; + } + + function normalizeUser(user, index = 0) { + const role = normalizeRole(user.role || inferRoleFromRemark(user.remark)); + const companyId = role === 'client' + ? (user.companyId || user.allowedCompanies?.[0] || DEFAULT_ROLE_COMPANIES[index % DEFAULT_ROLE_COMPANIES.length]) + : null; + return { + id: user.id || `u${String(index + 1).padStart(3, '0')}`, + displayName: user.displayName || user.loginName || '未命名用户', + loginName: String(user.loginName || '').toLowerCase(), + status: user.status || 'active', + lastLogin: user.lastLogin || '-', + role, + companyId, + allowedCompanies: role === 'client' + ? normalizeAllowedCompanies(user.allowedCompanies, companyId) + : DEFAULT_ROLE_COMPANIES.slice(), + remark: user.remark || (role === 'client' ? `${window.__getCompanyNameById?.(companyId) || companyId}-客户` : role === 'admin' ? '超级管理员' : '运营人员'), + }; + } + + function normalizeRole(role) { + const raw = String(role || '').toLowerCase(); + if (raw === 'admin' || raw === 'administrator') return 'admin'; + if (raw === 'worker' || raw === 'employee') return 'worker'; + if (raw === 'client' || raw === 'customer') return 'client'; + return 'worker'; + } + + function inferRoleFromRemark(remark) { + const text = String(remark || ''); + if (text.includes('超级管理员')) return 'admin'; + if (text.includes('客户')) return 'client'; + return 'worker'; + } + + function normalizeAllowedCompanies(value, companyId) { + if (Array.isArray(value) && value.length) return value; + if (companyId) return [companyId]; + return []; + } + + function getCompanyOptionsHtml(selectedCompanyId = '') { + const companies = (window.companies || []).filter(company => company.id !== 'all'); + return companies.map(company => ` + + `).join(''); + } + + function getUserById(userId) { + return users.find(user => user.id === userId); + } + + function getUserRoleLabel(user) { + if (user.role === 'admin') return '管理员'; + if (user.role === 'client') return '客户'; + return '运营人员'; + } + + function getUserRoleBadgeColor(user) { + if (user.role === 'admin') return '#722ed1'; + if (user.role === 'client') return '#52c41a'; + return '#1890ff'; + } + + function getCompanyName(companyId) { + return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : companyId; + } + + let users = loadUsers(); + window.__rbacUsers = users; + window.__lookupRbacUserByLoginName = function(loginName) { + const normalized = String(loginName || '').trim().toLowerCase(); + return users.find(user => user.loginName === normalized) || null; + }; + + const roles = [ + { name: 'Admin', desc: '超级管理员,拥有所有权限', color: '#722ed1', perms: ['全部功能'] }, + { name: 'Worker', desc: '运营人员,可配置应用,负责日常运营和 Bad Case 处理', color: '#1890ff', perms: ['仪表盘', '对话应用配置', '应用管理', '会话分析', 'Bad Case', '设备管理', 'OTA升级'] }, + { name: 'Client', desc: 'B端客户,只读访问自己公司的数据', color: '#52c41a', perms: ['仪表盘(只读)', '设备管理(只读)', 'OTA状态(只读)'] }, + ]; + + container.innerHTML = ` + + +
+
用户管理
+
角色配置
+
审计日志
+
+ + +
+
+
+ + + + + + + + + + + + ${users.map(u => { + const userType = getUserRoleLabel(u); + const typeColor = getUserRoleBadgeColor(u); + const companyTag = u.role === 'client' && u.companyId + ? `${escapeHtml(getCompanyName(u.companyId))}` + : ''; + return ` + + + + + + + + `}).join('')} + +
显示名称登录名称用户类型备注操作
+
+
${u.displayName[0]}
+ ${u.displayName} + ${u.status === 'active' + ? '' + : ''} +
+
${userType}${u.remark}${companyTag} + +
+
+
+
+ + +
+
+ ${roles.map(r => ` +
+
+ ${r.name} + ${r.perms.length} 项权限 +
+
${r.desc}
+
可访问功能:
+
+ ${r.perms.map(p => `${p}`).join('')} +
+
+ + +
+
+ `).join('')} +
+
+ + RBAC 接口:GET /api/v1/rbac/rolesPUT /api/v1/rbac/roles/{id}POST /api/v1/rbac/users/assign-role +
+
+ + +
+
+
+ + + + ${[ + { time: '14:22:05', user: '张明华', action: '登录', target: '系统', ip: '192.168.1.10', ok: true }, + { time: '14:18:30', user: '张明华', action: '修改 Prompt', target: '小Q故事机器人 v2.3', ip: '192.168.1.10', ok: true }, + { time: '13:55:12', user: '李思远', action: '关闭 Bad Case', target: 'BC-2026-004', ip: '10.0.2.15', ok: true }, + { time: '11:30:00', user: 'system', action: '自动触发内容安全', target: 'sess-3f7a', ip: 'internal', ok: false }, + { time: '09:15:42', user: '张明华', action: '创建 OTA 任务', target: 'OTA-2026-002', ip: '192.168.1.10', ok: true }, + ].map(a => ` + + + + + + + + + `).join('')} + +
时间操作人操作类型目标IP 地址结果
2026-05-12 ${a.time}${a.user}${a.action}${a.target}${a.ip}${a.ok ? '成功' : '告警'}
+
+
+
+ `; + + lucide.createIcons({ elements: [container] }); + + // Tab switch + container.querySelectorAll('#rbacTabs .tab-item').forEach(tab => { + tab.addEventListener('click', () => { + container.querySelectorAll('#rbacTabs .tab-item').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + container.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + container.querySelector(`#tab-${tab.dataset.tab}`)?.classList.add('active'); + }); + }); + + // Add user button + document.getElementById('addUserBtn')?.addEventListener('click', () => { + createModal({ + title: '添加用户', + content: ` +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
用户首次登录后需修改密码
+
`, + onConfirm: async () => { + const displayName = document.getElementById('newUserDisplayName')?.value.trim(); + const loginName = document.getElementById('newUserLoginName')?.value.trim(); + const role = document.getElementById('newUserRole')?.value; + const companyId = document.getElementById('newUserCompany')?.value; + const password = document.getElementById('newUserPassword')?.value; + if (!displayName || !loginName || !role || !password) { + Toast.warning('请填写用户必填信息'); + return false; + } + if (role === 'client' && !companyId) { + Toast.warning('Client 角色必须选择所属公司'); + return false; + } + const normalizedUser = normalizeUser({ + id: `u${Date.now()}`, + displayName, + loginName, + role, + companyId: role === 'client' ? companyId : null, + allowedCompanies: role === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(), + status: 'active', + lastLogin: '-', + remark: role === 'client' ? `${getCompanyName(companyId)}-客户` : role === 'admin' ? '超级管理员' : '运营人员', + }); + users.unshift(normalizedUser); + saveUsers(users); + await API.rbac.createUser({ displayName, loginName, role }); + Toast.success('用户创建成功,已发送激活邮件'); + navigateTo('rbac'); + }, + confirmText: '创建用户', + }); + }); + + // Global functions for action buttons + window.editUser = function(userId) { + const user = getUserById(userId); + if (!user) return; + + createModal({ + title: '编辑用户', + content: ` +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
仅 Client 角色需要配置,登录后只显示该公司客户可见内容
+
+
+ + +
`, + onConfirm: async () => { + const displayName = document.getElementById('editUserDisplayName')?.value.trim(); + const role = document.getElementById('editUserRole')?.value || 'worker'; + const companyId = document.getElementById('editUserCompany')?.value; + const remark = document.getElementById('editUserRemark')?.value.trim(); + if (role === 'client' && !companyId) { + Toast.warning('Client 角色必须选择所属公司'); + return false; + } + const nextUser = normalizeUser({ + ...user, + displayName: displayName || user.displayName, + role, + companyId: role === 'client' ? companyId : null, + allowedCompanies: role === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(), + remark: remark || (role === 'client' ? `${getCompanyName(companyId)}-客户` : role === 'admin' ? '超级管理员' : '运营人员'), + }); + const idx = users.findIndex(item => item.id === user.id); + if (idx >= 0) users[idx] = nextUser; + saveUsers(users); + await API.rbac.updateUser(userId, { + role: role, + allowedCompanies: role === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(), + }); + Toast.success(`用户 ${nextUser.displayName} 信息已更新`); + navigateTo('rbac'); + }, + confirmText: '保存', + }); + lucide.createIcons(); + }; + + // 切换公司访问配置显示 + window.toggleCompanyAccess = function(role) { + const group = document.getElementById('companyAccessGroup'); + const select = group?.querySelector('select'); + if (group) { + const normalized = String(role || '').toLowerCase(); + group.style.display = normalized === 'client' ? '' : 'none'; + if (select) select.disabled = normalized !== 'client'; + } + }; + + window.managePermissions = function(userId) { + const user = getUserById(userId); + if (!user) return; + + createModal({ + title: `权限管理 - ${user.displayName}`, + size: 'modal-lg', + content: ` +
+
为用户分配角色和细粒度权限
+
+ + +
+
+
+ +
+ +
${user.role === 'client' ? 'Client 角色仅可访问所选公司。' : '非 Client 角色可访问全部公司。'}
+
+ ${user.role === 'client' ? ` +
+ + Client 角色只能查看已授权公司的数据,无法进行 OTA 升级等操作 +
` : ''} +
+
+
细粒度权限
+
+ ${[ + { name: '数据仪表盘', checked: true, readonly: false }, + { name: '对话应用配置', checked: user.role !== 'client', readonly: user.role === 'client' }, + { name: '应用管理', checked: user.role !== 'client', readonly: user.role === 'client' }, + { name: '设备管理', checked: true, readonly: false }, + { name: 'OTA 升级', checked: user.role !== 'client', readonly: user.role === 'client' }, + { name: '会话详情', checked: true, readonly: false }, + { name: 'Bad Case 管理', checked: user.role !== 'client', readonly: user.role === 'client' }, + { name: '权限管理', checked: user.role !== 'client', readonly: true }, + { name: '系统设置', checked: user.role !== 'client', readonly: true }, + ].map(p => ` + + `).join('')} +
+
`, + onConfirm: () => { + const companyId = document.getElementById('permCompanySelect')?.value || user.companyId; + const nextRole = document.getElementById('permRoleSelect')?.value || 'client'; + const idx = users.findIndex(item => item.id === user.id); + if (idx >= 0) { + users[idx] = normalizeUser({ + ...user, + role: nextRole, + companyId: nextRole === 'client' ? companyId : null, + allowedCompanies: nextRole === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(), + remark: nextRole === 'client' ? `${getCompanyName(companyId)}-客户` : nextRole === 'admin' ? '超级管理员' : '运营人员', + }); + saveUsers(users); + } + Toast.success(`${user.displayName} 的权限已更新`); + navigateTo('rbac'); + }, + confirmText: '保存权限', + }); + lucide.createIcons(); + }; + + window.deleteUser = function(userId) { + const user = getUserById(userId); + if (!user) return; + + createModal({ + title: '确认删除', + content: ` +
+ +

确认删除用户 ${user.displayName}?

+

此操作不可撤销,删除后该用户将无法登录系统

+
`, + onConfirm: () => { + users = users.filter(item => item.id !== userId); + saveUsers(users); + Toast.success(`用户 ${user.displayName} 已删除`); + navigateTo('rbac'); + }, + confirmText: '确认删除', + cancelText: '取消', + }); + lucide.createIcons(); + }; + + window.editRolePermissions = function(roleName) { + const role = roles.find(r => r.name === roleName); + if (!role) return; + + // Client 角色特殊处理 - 需要配置公司级权限 + if (roleName === 'Client') { + createModal({ + title: '编辑 Client 角色权限', + content: ` +
+
角色说明
+
${role.desc}
+
+
+ +
+ ${role.perms.map(p => `${p}`).join('')} +
+
+
+ +
+ + + +
+
+ + 租户隔离:Client 角色只能访问其被授权公司的数据,Admin 可以将用户分配到特定公司 +
+
+
+ + 接口:PUT /api/v1/rbac/roles/client +
`, + onConfirm: async () => { + Toast.success('Client 角色权限已更新'); + }, + confirmText: '保存', + }); + lucide.createIcons(); + } else { + // Admin 和 Worker 角色显示功能权限 + createModal({ + title: `编辑 ${roleName} 角色权限`, + content: ` +
+
角色说明
+
${role.desc}
+
+
+ +
+
+ ${role.perms.map(p => `${p}`).join('')} +
+
+
+ ${roleName === 'Admin' ? ` +
+ +
✓ 可访问全部公司及全部功能
+
` : ` +
+ +
+ + + +
+
`} +
+ + 接口:PUT /api/v1/rbac/roles/${roleName.toLowerCase()} +
`, + onConfirm: async () => { + Toast.success(`${roleName} 角色权限已更新`); + }, + confirmText: '保存', + }); + lucide.createIcons(); + } + }; + + window.viewRoleUsers = function(roleName) { + const roleUsers = users.filter(u => { + if (roleName === 'Admin') return u.role === 'admin'; + if (roleName === 'Worker') return u.role === 'worker'; + if (roleName === 'Client') return u.role === 'client'; + return false; + }); + + createModal({ + title: `${roleName} 角色下的用户`, + content: ` +
+ 共 ${roleUsers.length} 个用户 +
+
+ ${roleUsers.map(u => ` +
+
${u.displayName[0]}
+
+
${u.displayName}
+
${u.loginName}
+
+
+ `).join('')} +
`, + confirmText: '关闭', + showCancel: false, + }); + }; +}); diff --git a/scripts/session.js b/scripts/session.js new file mode 100644 index 0000000..5096d8a --- /dev/null +++ b/scripts/session.js @@ -0,0 +1,578 @@ +/** + * TalkingQ Platform - Session Detail Page + */ + +const SESSION_TOPIC_DRILLDOWN_KEY = 'talkingq_session_topic_drilldown'; +const SESSION_VIEW_STATE_KEY = 'talkingq_session_view_state'; +const SESSION_DEFAULT_DATE = '2026-05-25'; +const SESSION_TOPIC_OPTIONS = ['讲故事', '唱儿歌', '问答学习', '天气查询', '闹钟设置', '游戏互动', '英语学习', '百科问答', '情感陪伴', '其他']; + +function getMockSessionData() { + const rows = [ + ['sess-a251', 'company_a', '智慧星科技', 'DEV-A001', '小Q故事机器人', '2026-05-25 14:22', '讲故事', 12, '8m 32s', 'good'], + ['sess-a252', 'company_a', '智慧星科技', 'DEV-A001', '小Q故事机器人', '2026-05-25 10:12', '百科问答', 8, '5m 28s', 'good'], + ['sess-a253', 'company_a', '智慧星科技', 'DEV-A005', '英语学习助手', '2026-05-25 13:48', '英语学习', 7, '5m 10s', 'good'], + ['sess-a254', 'company_a', '智慧星科技', 'DEV-A008', '小Q音乐伙伴', '2026-05-25 13:08', '唱儿歌', 10, '6m 48s', 'good'], + ['sess-a255', 'company_a', '智慧星科技', 'DEV-A011', '生活助手', '2026-05-25 12:36', '天气查询', 6, '4m 12s', 'neutral'], + ['sess-a256', 'company_a', '智慧星科技', 'DEV-A018', '设备小助手', '2026-05-25 11:52', '闹钟设置', 4, '2m 20s', 'good'], + ['sess-a257', 'company_a', '智慧星科技', 'DEV-A021', '小Q游戏伙伴', '2026-05-25 11:18', '游戏互动', 15, '11m 05s', 'good'], + ['sess-a258', 'company_a', '智慧星科技', 'DEV-A026', '陪伴机器人', '2026-05-25 10:40', '情感陪伴', 8, '5m 44s', 'good'], + ['sess-a241', 'company_a', '智慧星科技', 'DEV-A001', '小Q故事机器人', '2026-05-24 16:06', '讲故事', 9, '6m 16s', 'good'], + ['sess-a242', 'company_a', '智慧星科技', 'DEV-A005', '英语学习助手', '2026-05-24 14:20', '问答学习', 6, '4m 38s', 'neutral'], + ['sess-a231', 'company_a', '智慧星科技', 'DEV-A021', '小Q游戏伙伴', '2026-05-23 19:42', '游戏互动', 13, '9m 52s', 'good'], + + ['sess-b251', 'company_b', '未来玩具厂', 'DEV-B002', '学习机助手', '2026-05-25 14:05', '问答学习', 3, '1m 45s', 'bad'], + ['sess-b252', 'company_b', '未来玩具厂', 'DEV-B004', '科学百科探索者', '2026-05-25 13:42', '百科问答', 9, '7m 03s', 'neutral'], + ['sess-b253', 'company_b', '未来玩具厂', 'DEV-B008', '小Q故事机器人', '2026-05-25 13:18', '讲故事', 11, '8m 15s', 'good'], + ['sess-b254', 'company_b', '未来玩具厂', 'DEV-B015', '小Q音乐伙伴', '2026-05-25 12:46', '唱儿歌', 8, '4m 50s', 'good'], + ['sess-b255', 'company_b', '未来玩具厂', 'DEV-B019', '小Q游戏伙伴', '2026-05-25 11:56', '游戏互动', 14, '10m 22s', 'good'], + ['sess-b256', 'company_b', '未来玩具厂', 'DEV-B024', '生活助手', '2026-05-25 11:20', '天气查询', 5, '3m 06s', 'neutral'], + ['sess-b241', 'company_b', '未来玩具厂', 'DEV-B008', '小Q故事机器人', '2026-05-24 15:32', '讲故事', 10, '7m 30s', 'good'], + ['sess-b242', 'company_b', '未来玩具厂', 'DEV-B004', '科学百科探索者', '2026-05-24 12:14', '百科问答', 7, '5m 48s', 'good'], + + ['sess-c251', 'company_c', '童趣电子', 'DEV-C001', '小Q故事机器人', '2026-05-25 13:55', '讲故事', 18, '15m 20s', 'good'], + ['sess-c252', 'company_c', '童趣电子', 'DEV-C006', '小Q音乐伙伴', '2026-05-25 13:08', '唱儿歌', 9, '5m 35s', 'good'], + ['sess-c253', 'company_c', '童趣电子', 'DEV-C012', '学习机助手', '2026-05-25 12:32', '问答学习', 10, '6m 18s', 'good'], + ['sess-c254', 'company_c', '童趣电子', 'DEV-C018', '英语学习助手', '2026-05-25 11:54', '英语学习', 6, '4m 27s', 'neutral'], + ['sess-c255', 'company_c', '童趣电子', 'DEV-C022', '科学百科探索者', '2026-05-25 11:16', '百科问答', 12, '9m 11s', 'good'], + ['sess-c256', 'company_c', '童趣电子', 'DEV-C027', '陪伴机器人', '2026-05-25 10:45', '情感陪伴', 7, '4m 58s', 'good'], + ['sess-c257', 'company_c', '童趣电子', 'DEV-C031', '小Q助手', '2026-05-25 10:20', '其他', 2, '1m 02s', 'neutral'], + ['sess-c241', 'company_c', '童趣电子', 'DEV-C001', '小Q故事机器人', '2026-05-24 18:18', '讲故事', 16, '13m 44s', 'good'], + ['sess-c242', 'company_c', '童趣电子', 'DEV-C012', '学习机助手', '2026-05-24 09:48', '问答学习', 9, '6m 01s', 'good'], + ]; + + return rows.map(([id, companyId, companyName, device, agent, start, intent, turns, duration, quality]) => ({ + id, + companyId, + companyName, + device, + agent, + start, + date: start.slice(0, 10), + intent, + turns, + duration, + quality, + })); +} + +function readSessionTopicDrilldown() { + try { + const raw = sessionStorage.getItem(SESSION_TOPIC_DRILLDOWN_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed?.topic) return null; + if (parsed.createdAt && Date.now() - parsed.createdAt > 30 * 60 * 1000) { + sessionStorage.removeItem(SESSION_TOPIC_DRILLDOWN_KEY); + return null; + } + return parsed; + } catch (error) { + sessionStorage.removeItem(SESSION_TOPIC_DRILLDOWN_KEY); + return null; + } +} + +function readSessionViewState() { + try { + return JSON.parse(sessionStorage.getItem(SESSION_VIEW_STATE_KEY) || '{}') || {}; + } catch (error) { + return {}; + } +} + +function updateSessionViewState(patch) { + const next = { ...readSessionViewState(), ...patch }; + sessionStorage.setItem(SESSION_VIEW_STATE_KEY, JSON.stringify(next)); +} + +function setSessionTopicDrilldown(topic, context = {}) { + if (!topic) { + sessionStorage.removeItem(SESSION_TOPIC_DRILLDOWN_KEY); + return; + } + sessionStorage.setItem(SESSION_TOPIC_DRILLDOWN_KEY, JSON.stringify({ + topic, + companyId: context.companyId || 'all', + companyName: context.companyName || '全部公司', + source: context.source || 'session-filter', + createdAt: Date.now(), + })); +} + +function getSessionCurrentCompanyId() { + const currentRole = window.__getUserRole ? window.__getUserRole() : 'admin'; + const currentCompanyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : 'all'; + if (currentRole !== 'client') return currentCompanyId; + if (currentCompanyId !== 'all') return currentCompanyId; + try { + const user = JSON.parse(sessionStorage.getItem('talkingq_user') || 'null'); + return user?.companyId || user?.allowedCompanies?.[0] || currentCompanyId; + } catch (error) { + return currentCompanyId; + } +} + +function getSessionCompanyName(companyId, fallback = '当前公司') { + if (companyId === 'all') return '全部公司'; + return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : fallback; +} + +function sessionMatchesTopic(session, topic) { + if (!topic) return true; + const intent = String(session.intent || ''); + if (intent === topic) return true; + if (intent.includes(topic) || topic.includes(intent)) return true; + const aliases = { + '讲故事': ['故事', '睡前故事', '绘本'], + '唱儿歌': ['儿歌', '音乐', '唱歌'], + '问答学习': ['问答', '学习', '作业'], + '天气查询': ['天气'], + '闹钟设置': ['闹钟', '提醒'], + '游戏互动': ['游戏', '互动'], + '英语学习': ['英语', '英文'], + '百科问答': ['百科', '科普'], + '情感陪伴': ['陪伴', '情感', '聊天'], + '其他': ['其他'], + }; + return (aliases[topic] || []).some(keyword => intent.includes(keyword)); +} + +function sessionEscapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); +} + +function sessionEscapeAttr(value) { + return sessionEscapeHtml(value).replace(/"/g, '"'); +} + +function formatSessionDateLabel(date) { + const [year, month, day] = String(date || '').split('-'); + return year && month && day ? `${year}.${month}.${day}` : date; +} + +function getSessionQualityText(quality) { + if (quality === 'good') return '优质'; + if (quality === 'bad') return '待优化'; + return '正常'; +} + +function getSessionQualityBadge(quality) { + if (quality === 'good') return 'badge-success'; + if (quality === 'bad') return 'badge-danger'; + return 'badge-default'; +} + +function getTopicOptionsForCompany(sessions) { + const seen = new Set(sessions.map(item => item.intent)); + return SESSION_TOPIC_OPTIONS.filter(topic => seen.has(topic)); +} + +function getCompanyDevices(companySessions, daySessions) { + const byDevice = new Map(); + companySessions.forEach(session => { + if (!byDevice.has(session.device)) { + byDevice.set(session.device, { + device: session.device, + companyId: session.companyId, + companyName: session.companyName, + agent: session.agent, + sessions: [], + }); + } + }); + daySessions.forEach(session => { + if (!byDevice.has(session.device)) { + byDevice.set(session.device, { + device: session.device, + companyId: session.companyId, + companyName: session.companyName, + agent: session.agent, + sessions: [], + }); + } + byDevice.get(session.device).sessions.push(session); + }); + return Array.from(byDevice.values()).map(item => { + const topics = Array.from(new Set(item.sessions.map(session => session.intent))); + const turns = item.sessions.reduce((sum, session) => sum + session.turns, 0); + const lastSession = item.sessions.slice().sort((a, b) => b.start.localeCompare(a.start))[0]; + return { + ...item, + topics, + turns, + count: item.sessions.length, + lastSession, + }; + }).sort((a, b) => b.count - a.count || a.device.localeCompare(b.device)); +} + +function getConversationLines(topic) { + const samples = { + '讲故事': ['小Q,给我讲一个关于恐龙的故事吧!', '好的!从前,在一片茂密的丛林里,住着一只叫做小雷的霸王龙宝宝……', '霸王龙是最厉害的恐龙吗?', '霸王龙很厉害,但恐龙世界里还有很多不同种类的大家伙。我们可以一起认识它们。'], + '唱儿歌': ['小Q,唱一首上学路上的儿歌。', '当然可以,我会用轻快一点的节奏来唱。', '再唱一首动物相关的。', '好的,这次我们唱一首关于小兔子的儿歌。'], + '问答学习': ['小Q,为什么月亮会变圆又变弯?', '这是因为月亮围着地球转,我们看到被太阳照亮的部分会变化。', '那满月是什么时候?', '当我们看到月亮被照亮的一面几乎完整朝向地球时,就是满月。'], + '天气查询': ['小Q,今天要带雨伞吗?', '今天下午可能有小雨,建议带一把轻便雨伞。', '那适合出去玩吗?', '可以短时间外出,尽量避开下午降雨时段。'], + '闹钟设置': ['小Q,明天早上七点叫我起床。', '好的,已为你设置明天早上七点的起床提醒。', '再加一个七点十分。', '已添加七点十分的第二个提醒。'], + '游戏互动': ['小Q,我们玩猜动物吧。', '好,我来描述一个动物,你来猜。它有长长的鼻子和大大的耳朵。', '是大象!', '答对了!下一题要不要难一点?'], + '英语学习': ['How do I say 小熊 in English?', '小熊 can be said as little bear or teddy bear, depending on context.', 'Can you make a sentence?', 'Sure. The little bear is sleeping under the tree.'], + '百科问答': ['小Q,火山为什么会喷发?', '地下的岩浆和气体压力变大时,会沿着裂缝冲出来,就形成了火山喷发。', '岩浆冷了会变成什么?', '岩浆冷却后会变成岩石,有些还会形成新的地貌。'], + '情感陪伴': ['小Q,我今天有点不开心。', '我在呢。你愿意告诉我发生了什么吗?', '我和朋友吵架了。', '听起来你很难过。我们可以先深呼吸,再想想怎么和朋友好好说。'], + '其他': ['小Q,你现在能做什么?', '我可以聊天、讲故事、唱儿歌、回答问题,也可以帮你做一些简单提醒。', '那我们随便聊聊吧。', '好呀,你今天最开心的一件事是什么?'], + }; + return samples[topic] || samples['其他']; +} + +function renderSessionConversation(session, options = {}) { + if (!session) { + return '

暂无可查看的对话内容

'; + } + const lines = getConversationLines(session.intent); + const hiddenTurns = Math.max(session.turns - 4, 0); + return ` +
+
用户
+
${sessionEscapeHtml(lines[0])}
+
+
+
Q
+
${sessionEscapeHtml(lines[1])}
+
+
+
用户
+
${sessionEscapeHtml(lines[2])}
+
+
+
Q
+
${sessionEscapeHtml(lines[3])}
+
+ ${options.showMore !== false ? `
还有 ${hiddenTurns} 轮对话
` : ''} + `; +} + +function renderDeviceRows(devices, selectedDeviceId, showCompanyColumn) { + if (!devices.length) { + return `暂无设备数据`; + } + return devices.map(device => { + const active = device.device === selectedDeviceId; + return ` + + +
${sessionEscapeHtml(device.device)}
+
${sessionEscapeHtml(device.agent)}
+ + ${showCompanyColumn ? `${sessionEscapeHtml(getSessionCompanyName(device.companyId, device.companyName))}` : ''} + ${device.count} 条 + ${device.turns} 轮 + ${device.topics.length ? device.topics.slice(0, 3).map(topic => `${sessionEscapeHtml(topic)}`).join('') : '当天无对话'} + ${device.lastSession ? sessionEscapeHtml(device.lastSession.start.slice(11)) : '-'} + + + `; + }).join(''); +} + +function renderDeviceSessionList(device, selectedDate) { + if (!device) { + return ` +
+
+
+

设备对话历史

+

${formatSessionDateLabel(selectedDate)} · 从左侧选择设备查看对话内容

+
+
+

请选择设备查看当天对话历史

+
+ `; + } + if (!device.sessions.length) { + return ` +
+
+
+

${sessionEscapeHtml(device.device)}

+

${formatSessionDateLabel(selectedDate)} 当天暂无对话

+
+ +
+

该设备在所选日期没有产生对话

+
+ `; + } + return ` +
+
+
+

${sessionEscapeHtml(device.device)}

+

${formatSessionDateLabel(selectedDate)} · ${device.sessions.length} 条对话 · ${device.turns} 轮互动

+
+
+ ${sessionEscapeHtml(device.agent)} + +
+
+
+ ${device.sessions.map(session => ` +
+
+
+ ${sessionEscapeHtml(session.intent)} + ${sessionEscapeHtml(session.start.slice(11))} · ${session.turns} 轮 · ${sessionEscapeHtml(session.duration)} +
+ ${getSessionQualityText(session.quality)} +
+
+ ${renderSessionConversation(session)} +
+
+ `).join('')} +
+
+ `; +} + +function renderTopicAggregateView(sessions, selectedTopic, selectedDate, companyName) { + const totalTurns = sessions.reduce((sum, session) => sum + session.turns, 0); + const agents = new Set(sessions.map(session => session.agent)); + return ` +
+
+
+ 话题 + ${sessionEscapeHtml(selectedTopic)} +
+
+ 日期 + ${formatSessionDateLabel(selectedDate)} +
+
+ 会话数 + ${sessions.length} +
+
+ 总轮次 + ${totalTurns} +
+
+
+
+
+
「${sessionEscapeHtml(selectedTopic)}」对话内容整合
+
${sessionEscapeHtml(companyName)} 全部设备聚合 · ${agents.size} 个智能体参与 · 不按设备区分
+
+
+ ${sessions.length ? ` +
+ ${sessions.map(session => ` +
+
+
+ ${sessionEscapeHtml(session.agent)} + ${sessionEscapeHtml(session.start.slice(11))} · ${session.turns} 轮 · ${sessionEscapeHtml(session.duration)} +
+ ${getSessionQualityText(session.quality)} +
+
+ ${renderSessionConversation(session)} +
+
+ `).join('')} +
+ ` : `

所选日期暂无「${sessionEscapeHtml(selectedTopic)}」相关对话

`} +
+
+ `; +} + +registerPage('session', function(container) { + const currentRole = window.__getUserRole ? window.__getUserRole() : 'admin'; + const isClient = currentRole === 'client'; + const currentCompanyId = getSessionCurrentCompanyId(); + const currentCompanyName = getSessionCompanyName(currentCompanyId, '当前公司'); + const viewState = readSessionViewState(); + const topicDrilldown = readSessionTopicDrilldown(); + const selectedTopic = topicDrilldown?.topic || ''; + const fromDashboardDrilldown = topicDrilldown?.source === 'dashboard-topic-ranking'; + const selectedDate = viewState.date || topicDrilldown?.date || SESSION_DEFAULT_DATE; + const allSessions = getMockSessionData(); + const companySessions = currentCompanyId === 'all' + ? allSessions + : allSessions.filter(session => session.companyId === currentCompanyId); + const daySessions = companySessions.filter(session => session.date === selectedDate); + const topicSessions = selectedTopic + ? daySessions.filter(session => sessionMatchesTopic(session, selectedTopic)) + : []; + const devices = getCompanyDevices(companySessions, daySessions); + const selectedDeviceId = selectedTopic + ? '' + : (viewState.deviceId && devices.some(device => device.device === viewState.deviceId) ? viewState.deviceId : ''); + const selectedDevice = devices.find(device => device.device === selectedDeviceId); + const showCompanyColumn = !isClient && currentCompanyId === 'all'; + const topicOptions = getTopicOptionsForCompany(companySessions); + const deviceCount = devices.length; + const dayTurns = daySessions.reduce((sum, session) => sum + session.turns, 0); + const pageDesc = selectedTopic + ? `${currentCompanyName} · 按热门话题聚合查看对话内容` + : `${currentCompanyName} · 按设备查看所选日期的对话历史`; + + container.innerHTML = ` + + + ${selectedTopic ? ` +
+ +
+
+
+
+ 热门话题聚合:${sessionEscapeHtml(selectedTopic)} + ${sessionEscapeHtml(currentCompanyName)} · ${formatSessionDateLabel(selectedDate)} · 当前结果 ${topicSessions.length} 条 · 不区分设备 +
+
+ ` : ''} + +
+
+
+ + +
+
+ + +
+
+
+
+ 设备 + ${deviceCount} +
+
+ 当日会话 + ${selectedTopic ? topicSessions.length : daySessions.length} +
+
+ 互动轮次 + ${selectedTopic ? topicSessions.reduce((sum, session) => sum + session.turns, 0) : dayTurns} +
+
+
+ + ${selectedTopic ? renderTopicAggregateView(topicSessions, selectedTopic, selectedDate, currentCompanyName) : ` +
+
+
+
+
全部设备
+
${formatSessionDateLabel(selectedDate)} · 点击设备查看当天对话历史
+
+
+
+ + + + + ${showCompanyColumn ? '' : ''} + + + + + + + + ${renderDeviceRows(devices, selectedDeviceId, showCompanyColumn)} +
设备所属公司当日会话轮次涉及话题最后对话操作
+
+
+
+ ${renderDeviceSessionList(selectedDevice, selectedDate)} +
+
+ `} + `; + + lucide.createIcons({ elements: [container] }); + + const backToAllTopics = () => { + sessionStorage.removeItem(SESSION_TOPIC_DRILLDOWN_KEY); + navigateTo('session'); + }; + + container.querySelector('#sessionStepBackBtn')?.addEventListener('click', () => { + sessionStorage.removeItem(SESSION_TOPIC_DRILLDOWN_KEY); + if (fromDashboardDrilldown) { + navigateTo('dashboard'); + return; + } + navigateTo('session'); + }); + + container.querySelector('#clearSessionTopicFilter')?.addEventListener('click', backToAllTopics); + container.querySelectorAll('[data-session-back-topic]').forEach(button => { + button.addEventListener('click', backToAllTopics); + }); + + container.querySelectorAll('[data-session-back-device]').forEach(button => { + button.addEventListener('click', () => { + updateSessionViewState({ deviceId: '' }); + navigateTo('session'); + }); + }); + + container.querySelector('#sessionDateFilter')?.addEventListener('change', event => { + updateSessionViewState({ date: event.target.value || SESSION_DEFAULT_DATE, deviceId: '' }); + navigateTo('session'); + }); + + container.querySelector('#sessionTopicFilter')?.addEventListener('change', event => { + const topic = event.target.value; + setSessionTopicDrilldown(topic, { + companyId: currentCompanyId, + companyName: currentCompanyName, + source: 'session-filter', + date: selectedDate, + }); + navigateTo('session'); + }); + + const selectDevice = deviceId => { + if (!deviceId) return; + updateSessionViewState({ deviceId }); + navigateTo('session'); + }; + + container.querySelectorAll('.session-device-row').forEach(row => { + row.addEventListener('click', () => { + selectDevice(row.dataset.deviceId); + }); + }); + + container.querySelectorAll('.view-device-btn').forEach(button => { + button.addEventListener('click', event => { + event.stopPropagation(); + selectDevice(button.dataset.deviceId); + }); + }); +}); diff --git a/scripts/settings.js b/scripts/settings.js new file mode 100644 index 0000000..cb5f50f --- /dev/null +++ b/scripts/settings.js @@ -0,0 +1,406 @@ +/** + * TalkingQ Platform - Settings Page + */ + +function getNotificationCatalog() { + return window.TQ_NOTIFICATION_EVENT_CATALOG || [ + { + type: 'device.offline_rate_exceeded', + category: '设备健康', + name: '设备离线率异常', + severity: 'critical', + trigger: '公司设备离线率连续 10 分钟超过阈值', + ruleSummary: '离线率 > 5%,且连续 10 分钟未恢复', + window: '10 分钟连续检测', + cooldown: '30 分钟内同公司同规则只通知 1 次', + contentFields: ['公司名称', '当前离线率', '影响设备数', '最近检测时间', '建议处理动作'], + defaultThreshold: 5, + thresholdUnit: '%', + defaultChannels: ['in_app', 'webhook'], + }, + ]; +} + +function getNotificationInputKey(type) { + return String(type).replace(/[^a-zA-Z0-9]/g, '_'); +} + +function getNotificationSeverityLabel(severity) { + return { + critical: '高危', + warning: '预警', + info: '提醒', + success: '完成', + }[severity] || '提醒'; +} + +function getDefaultNotificationRules(settings = {}) { + const legacyBadcaseEnabled = settings.notifyBadcase !== false; + const legacyOfflineEnabled = settings.notifyOffline !== false; + const legacyOTAEnabled = settings.notifyOTA !== false; + + return getNotificationCatalog().reduce((rules, item) => { + let enabled = item.defaultEnabled !== false; + if (item.type === 'device.offline_rate_exceeded') enabled = legacyOfflineEnabled; + if (item.type === 'ota.task_failed') enabled = legacyOTAEnabled; + if (item.type === 'ota.failure_rate_exceeded') enabled = legacyOTAEnabled; + if (item.type === 'ai.badcase_rate_exceeded') enabled = legacyBadcaseEnabled; + + rules[item.type] = { + enabled, + threshold: item.type === 'ai.badcase_rate_exceeded' + ? (settings.badcaseThreshold || item.defaultThreshold || '') + : (item.defaultThreshold || ''), + channels: item.defaultChannels || ['in_app'], + }; + return rules; + }, {}); +} + +function getNotificationRules(settings = {}) { + const defaults = getDefaultNotificationRules(settings); + const saved = settings.notificationRules || {}; + return Object.fromEntries(Object.entries(defaults).map(([type, rule]) => [ + type, + { + ...rule, + ...(saved[type] || {}), + channels: saved[type]?.channels || rule.channels, + }, + ])); +} + +function renderNotificationRuleRows(rules) { + return getNotificationCatalog().map(item => { + const key = getNotificationInputKey(item.type); + const rule = rules[item.type] || {}; + const channels = new Set(rule.channels || item.defaultChannels || ['in_app']); + const hasThreshold = item.defaultThreshold !== undefined; + const thresholdText = item.thresholdLabel || (hasThreshold ? `${rule.threshold || item.defaultThreshold}${item.thresholdUnit || ''}` : '发生即通知'); + return ` +
+
+
+
+
${item.name}
+
${item.trigger}
+
+ ${getNotificationSeverityLabel(item.severity)} +
+
+ 规则:${item.ruleSummary || thresholdText} + 窗口:${item.window || '实时'} + 冷却:${item.cooldown || '无'} +
+
+ 通知内容:${item.contentFields.join('、')} +
+
+
+ + ${hasThreshold ? ` + + ` : ''} + ${!hasThreshold ? ` +
+ 阈值 + ${thresholdText} +
+ ` : ''} +
+ + + +
+
+
+ `; + }).join(''); +} + +registerPage('settings', function(container) { + // Load saved settings + const settings = loadSettings(); + const notificationRules = getNotificationRules(settings); + + container.innerHTML = ` + + +
+ +
+
+
+
API 密钥配置
+
配置 AI 模型服务的 API 密钥
+
+
+
+
+ +
+ + +
+
用于 GPT-4o、GPT-4o mini 等模型调用
+
+ +
+ +
+ + +
+
用于通义千问等阿里云大模型服务
+
+ +
+ +
+ + +
+
用于 Gemini 1.5 Flash 等模型
+
+ +
+ + +
+
+
+ + +
+
+
+
平台配置
+
配置系统级参数
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
+ + +
+
+
+
通知设置
+
只通知需要关注或处理的事件,包含异常、异步任务和关键生命周期操作
+
+
+
+
+
+ 异常告警 + 设备、AI 安全、OTA 失败等需要处理的异常。 +
+
+ 异步结果 + 应用发布、知识库索引等耗时任务的失败或完成结果。 +
+
+ 安全权限 + 异常登录、管理员变更、API Key 创建/撤销、权限异常变更。 +
+
+
+ ${renderNotificationRuleRows(notificationRules)} +
+ +
+ +
+
+
+
+ `; + + lucide.createIcons({ elements: [container] }); +}); + +// Load settings from in-memory demo state +function loadSettings() { + if (window.__talkingqSettings) { + return JSON.parse(JSON.stringify(window.__talkingqSettings)); + } + try { + localStorage.removeItem('talkingq_settings'); + } catch (e) { + console.warn('Failed to clear legacy settings cache', e); + } + return {}; +} + +// Save settings to in-memory demo state +function saveSettings(settings) { + window.__talkingqSettings = JSON.parse(JSON.stringify(settings || {})); +} + +// Toggle API key visibility +function toggleApiKeyVisibility(inputId, btn) { + const input = document.getElementById(inputId); + if (input) { + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + btn.innerHTML = ``; + btn.setAttribute('title', isPassword ? '隐藏密钥' : '显示密钥'); + lucide.createIcons({ elements: [btn] }); + } +} + +async function testApiConnection(btn) { + const keys = ['apiKeyOpenai', 'apiKeyAli', 'apiKeyGemini'] + .map(id => document.getElementById(id)?.value.trim()) + .filter(Boolean); + if (keys.length === 0) { + Toast.warning('请先填写至少一个 API Key'); + return; + } + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' 测试中...'; + lucide.createIcons({ elements: [btn] }); + await new Promise(resolve => setTimeout(resolve, 800)); + btn.disabled = false; + btn.innerHTML = original; + lucide.createIcons({ elements: [btn] }); + Toast.success('API 连接测试通过'); +} + +// Save API Keys +function saveApiKeys() { + const settings = loadSettings(); + settings.apiKeys = { + openai: document.getElementById('apiKeyOpenai')?.value || '', + ali: document.getElementById('apiKeyAli')?.value || '', + gemini: document.getElementById('apiKeyGemini')?.value || '' + }; + saveSettings(settings); + Toast.success('API 密钥已保存'); +} + +// Save Platform Settings +function savePlatformSettings() { + const settings = loadSettings(); + settings.platformName = document.getElementById('platformName')?.value || 'TalkingQ'; + settings.refreshInterval = parseInt(document.getElementById('refreshInterval')?.value || '300'); + settings.defaultModel = document.getElementById('defaultModel')?.value || 'gpt-4o-mini'; + settings.enableDebug = document.getElementById('enableDebug')?.checked || false; + saveSettings(settings); + Toast.success('平台配置已保存'); +} + +// Save Notification Settings +async function saveNotificationSettings() { + const settings = loadSettings(); + settings.notificationRules = {}; + + getNotificationCatalog().forEach(item => { + const key = getNotificationInputKey(item.type); + const channels = ['in_app']; + if (document.getElementById(`notif_${key}_channel_email`)?.checked) channels.push('email'); + if (document.getElementById(`notif_${key}_channel_webhook`)?.checked) channels.push('webhook'); + + settings.notificationRules[item.type] = { + enabled: document.getElementById(`notif_${key}_enabled`)?.checked || false, + threshold: item.defaultThreshold !== undefined + ? parseInt(document.getElementById(`notif_${key}_threshold`)?.value || item.defaultThreshold, 10) + : undefined, + thresholdUnit: item.thresholdUnit || undefined, + thresholdLabel: item.thresholdLabel || undefined, + window: item.window || undefined, + cooldown: item.cooldown || undefined, + channels, + }; + }); + + settings.notifyBadcase = settings.notificationRules['ai.badcase_rate_exceeded']?.enabled !== false; + settings.badcaseThreshold = settings.notificationRules['ai.badcase_rate_exceeded']?.threshold || 5; + settings.notifyOffline = settings.notificationRules['device.offline_rate_exceeded']?.enabled !== false; + settings.notifyOTA = settings.notificationRules['ota.task_failed']?.enabled !== false; + + saveSettings(settings); + try { + await API.settings.updateNotifications({ + rules: settings.notificationRules, + tenantScope: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {}, + }); + } catch (error) { + Toast.warning('通知设置已保存在前端,后端接口接入后会同步保存'); + return; + } + window.__refreshNotifications?.(); + Toast.success('通知设置已保存'); +} + +// Get API Key helper +function getApiKey(provider) { + const settings = loadSettings(); + return settings.apiKeys?.[provider] || ''; +} diff --git a/styles/main.css b/styles/main.css new file mode 100644 index 0000000..bee2abe --- /dev/null +++ b/styles/main.css @@ -0,0 +1,9553 @@ +/* ========================================== + TalkingQ Platform - Main Stylesheet + Color Brand: #52c41a (green), #1a1a1a (black) + ========================================== */ + +:root { + --primary: #52c41a; + --primary-dark: #389e0d; + --primary-light: #b7eb8f; + --primary-bg: #f6ffed; + --primary-gradient: linear-gradient(135deg, #73d13d 0%, #389e0d 100%); + + --black: #1a1a1a; + --text-primary: #1a1a1a; + --text-secondary: #595959; + --text-muted: #8c8c8c; + --text-placeholder: #bfbfbf; + + --bg-base: #f5f5f5; + --bg-white: #ffffff; + --bg-card: #ffffff; + --bg-sidebar: #141414; + + --border: #e8e8e8; + --border-dark: #d9d9d9; + + --success: #52c41a; + --warning: #faad14; + --danger: #ff4d4f; + --info: #1890ff; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + + --shadow-sm: 0 1px 4px rgba(0,0,0,0.06); + --shadow: 0 2px 12px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.12); + --shadow-green: 0 4px 20px rgba(82,196,26,0.25); + + --sidebar-width: 240px; + --sidebar-collapsed: 76px; + --topbar-height: 60px; + + --transition: all 0.2s ease; +} + +/* ========== 暗黑模式支持 ========== */ +[data-theme="dark"] { + --bg-base: #1a1a1a; + --bg-white: #262626; + --bg-card: #262626; + --text-primary: #ffffff; + --text-secondary: #a6a6a6; + --text-muted: #737373; + --border: #434343; + --border-dark: #595959; + --bg-sidebar: #0a0a0a; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', + 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 14px; + color: var(--text-primary); + background: var(--bg-base); + overflow: hidden; +} + +a { text-decoration: none; color: inherit; } +button { cursor: pointer; border: none; background: none; font-family: inherit; } + +/* ========== SCROLLBAR ========== */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #bfbfbf; } + +/* ========== LAYOUT ========== */ +body { display: flex; } + +/* Login mode - hide sidebar and topbar */ +body.login-mode { + display: block !important; + overflow: hidden !important; +} + +body.login-mode .sidebar { display: none !important; } +body.login-mode .topbar { display: none !important; } +body.login-mode .main-content { + width: 100% !important; + margin-left: 0 !important; + height: 100vh !important; +} +body.login-mode .page-container { + padding: 0 !important; + overflow: hidden !important; + height: 100vh !important; +} + +.sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + height: 100vh; + background: var(--bg-sidebar); + display: flex; + flex-direction: column; + transition: width 0.25s ease, min-width 0.25s ease; + overflow: hidden; + position: relative; + z-index: 100; +} + +.sidebar.collapsed { + width: var(--sidebar-collapsed); + min-width: var(--sidebar-collapsed); +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* ========== SIDEBAR HEADER ========== */ +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + height: var(--topbar-height); + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; +} + +.logo-icon { + width: 34px; + height: 34px; + flex-shrink: 0; +} + +.logo-icon svg { width: 100%; height: 100%; } + +.logo-text { + font-size: 18px; + font-weight: 700; + color: #fff; + white-space: nowrap; + letter-spacing: -0.3px; +} + +.sidebar-toggle { + color: rgba(255,255,255,0.7); + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 34px; + border-radius: 12px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.08); + transition: var(--transition); + flex-shrink: 0; + cursor: pointer; +} + +.sidebar-toggle:hover { + color: #fff; + background: rgba(82,196,26,0.18); + border-color: rgba(82,196,26,0.26); +} + +.sidebar-toggle i, +.sidebar-toggle svg { + width: 18px; + height: 18px; + transition: transform 0.2s ease; +} + +.sidebar.collapsed .sidebar-toggle i, +.sidebar.collapsed .sidebar-toggle svg { + transform: rotate(180deg); +} + +/* ========== SIDEBAR NAV ========== */ +.sidebar-nav { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 0; +} + +.nav-section { + margin-bottom: 4px; + padding: 0 8px; +} + +.nav-section-title { + font-size: 11px; + color: rgba(255,255,255,0.28); + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 10px 8px 6px; + white-space: nowrap; + display: block; + transition: opacity 0.2s; +} + +.sidebar.collapsed .nav-section-title { opacity: 0; } + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: var(--radius-sm); + color: rgba(255,255,255,0.65); + transition: var(--transition); + position: relative; + white-space: nowrap; +} + +.nav-item:hover { + color: #fff; + background: rgba(255,255,255,0.06); +} + +.nav-item.active { + color: #fff; + background: linear-gradient(90deg, rgba(82,196,26,0.25) 0%, rgba(82,196,26,0.08) 100%); + border-left: 3px solid var(--primary); + padding-left: 7px; +} + +.nav-parent { + cursor: pointer; +} + +.nav-chevron { + margin-left: auto; + width: 14px !important; + height: 14px !important; + transition: transform 0.2s; +} + +.nav-parent[aria-expanded="false"] .nav-chevron { + transform: rotate(-90deg); +} + +.nav-submenu { + display: none; + margin: 2px 0 4px; +} + +.nav-submenu.open { + display: block; +} + +.nav-item-sub { + padding-left: 34px; + font-size: 13px; +} + +.nav-item-sub.active { + padding-left: 31px; +} + +.nav-item i { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.nav-item span:not(.nav-badge) { + font-size: 13.5px; + transition: opacity 0.15s; +} + +.sidebar.collapsed .nav-item span:not(.nav-badge) { opacity: 0; pointer-events: none; } +.sidebar.collapsed .nav-section-title { height: 0; padding: 0; overflow: hidden; } +.sidebar.collapsed .nav-submenu { display: none; } +.sidebar.collapsed .nav-chevron { + display: none !important; +} + +.nav-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 10px; + background: var(--primary); + color: #fff; + margin-left: auto; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.sidebar.collapsed .nav-badge { opacity: 0; } + +/* ========== SIDEBAR FOOTER ========== */ +.sidebar-footer { + border-top: 1px solid rgba(255,255,255,0.06); + padding: 12px; + flex-shrink: 0; +} + +.user-info { + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary-gradient); + color: #fff; + font-weight: 700; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.user-details { + flex: 1; + overflow: hidden; + transition: opacity 0.2s; +} + +.sidebar.collapsed .user-details { opacity: 0; } + +.user-name { + display: block; + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; +} + +.user-role { + display: block; + font-size: 11px; + color: rgba(255,255,255,0.45); + white-space: nowrap; +} + +.logout-btn { + color: rgba(255,255,255,0.4); + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; + transition: var(--transition); + flex-shrink: 0; +} + +.logout-btn:hover { color: var(--danger); } +.sidebar.collapsed .logout-btn { opacity: 0; } + +/* ========== SIDEBAR COLLAPSED RAIL ========== */ +.sidebar.collapsed { + overflow: visible; + background: linear-gradient(180deg, #111713 0%, #0c100e 100%); + border-right: 1px solid rgba(255,255,255,0.07); + box-shadow: 8px 0 26px rgba(0,0,0,0.12); +} + +.sidebar.collapsed .sidebar-header { + flex-direction: column; + justify-content: center; + gap: 7px; + height: 94px; + padding: 10px 0 8px; + border-bottom: 0; +} + +.sidebar.collapsed .logo { + width: 40px; + height: 40px; + justify-content: center; + border-radius: 14px; + background: linear-gradient(135deg, #52c41a 0%, #1f7a38 100%); + border: 1px solid rgba(255,255,255,0.14); + box-shadow: 0 10px 24px rgba(82,196,26,0.24); + overflow: visible; +} + +.sidebar.collapsed .logo::before { + content: 'TQ'; + color: #fff; + font-size: 14px; + font-weight: 800; + letter-spacing: 0; + line-height: 1; +} + +.sidebar.collapsed .logo-text { + width: 0; + opacity: 0; + overflow: hidden; +} + +.sidebar.collapsed .sidebar-toggle { + position: static; + width: 40px; + height: 30px; + border-radius: 12px; + color: rgba(255,255,255,0.74); + background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.1); + box-shadow: none; +} + +.sidebar.collapsed .sidebar-toggle:hover { + color: #fff; + background: rgba(82,196,26,0.18); + border-color: rgba(82,196,26,0.32); + transform: none; +} + +.sidebar.collapsed .sidebar-nav { + padding: 0 0 12px; + overflow: visible; +} + +.sidebar.collapsed .nav-section { + position: relative; + width: 100%; + padding: 4px 0; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.sidebar.collapsed .nav-section + .nav-section { + border-top: 0; + padding-top: 8px; +} + +.sidebar.collapsed .nav-section + .nav-section::before { + content: ''; + display: block; + width: 28px; + height: 1px; + margin: 0 auto 7px; + background: rgba(255,255,255,0.08); +} + +.sidebar.collapsed .nav-item, +.sidebar.collapsed .company-nav-item { + width: 44px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + margin: 2px auto; + padding: 0; + border-radius: 14px; + line-height: 0; +} + +.sidebar.collapsed .nav-parent { + gap: 0; +} + +.sidebar.collapsed .nav-item { + color: rgba(255,255,255,0.56); + border: 1px solid transparent; +} + +.sidebar.collapsed .nav-item:hover { + color: #fff; + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.08); + box-shadow: none; + transform: none; +} + +.sidebar.collapsed .nav-item.active { + color: #fff; + padding-left: 0; + border-left: 0; + background: rgba(82,196,26,0.18); + border-color: rgba(82,196,26,0.24); + box-shadow: inset 0 0 0 1px rgba(82,196,26,0.08); +} + +.sidebar.collapsed .nav-item.active::before { + content: ''; + position: absolute; + left: -9px; + top: 10px; + width: 3px; + height: 20px; + border-radius: 999px; + background: #52c41a; + box-shadow: 0 0 12px rgba(82,196,26,0.65); +} + +.sidebar.collapsed .nav-item i, +.sidebar.collapsed .nav-item svg, +.sidebar.collapsed .company-nav-item i, +.sidebar.collapsed .company-nav-item svg { + width: 18px !important; + height: 18px !important; + stroke-width: 2.15; + flex-shrink: 0; +} + +.sidebar.collapsed .nav-item span:not(.nav-badge) { + position: absolute; + left: 60px; + top: 50%; + z-index: 120; + width: max-content; + max-width: 220px; + padding: 8px 11px; + border-radius: 11px; + background: rgba(18,26,21,0.96); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; + font-size: 12px; + font-weight: 600; + opacity: 0; + pointer-events: none; + transform: translate(-6px, -50%); + box-shadow: 0 14px 34px rgba(19,45,26,0.16); +} + +.sidebar.collapsed .nav-item span:not(.nav-badge)::before { + content: ''; + position: absolute; + left: -5px; + top: 50%; + width: 10px; + height: 10px; + background: rgba(18,26,21,0.96); + border-left: 1px solid rgba(255,255,255,0.1); + border-bottom: 1px solid rgba(255,255,255,0.1); + transform: translateY(-50%) rotate(45deg); +} + +.sidebar.collapsed .nav-item:hover span:not(.nav-badge) { + opacity: 1; + transform: translate(0, -50%); +} + +.sidebar.collapsed .sidebar-footer { + padding: 8px 0 12px; + border-top: 0; +} + +.sidebar.collapsed .user-info { + width: 44px; + height: 44px; + justify-content: center; + gap: 0; + margin: 0 auto; + border-radius: 15px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.08); + box-shadow: none; +} + +.sidebar.collapsed .user-details, +.sidebar.collapsed .logout-btn { + display: none; +} + +.sidebar.collapsed .user-avatar { + width: 34px; + height: 34px; + border-radius: 13px; + background: linear-gradient(135deg, #52c41a 0%, #1f7a38 100%); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.14); +} + +/* ========== TOPBAR ========== */ +.topbar { + height: var(--topbar-height); + background: var(--bg-white); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + flex-shrink: 0; + gap: 16px; + position: sticky; + top: 0; + z-index: 100; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 16px; +} + +.menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + transition: var(--transition); +} + +.menu-btn:hover { background: var(--bg-base); color: var(--text-primary); } + +.breadcrumb { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.topbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-time { + font-size: 13px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.topbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + transition: var(--transition); + position: relative; +} + +.topbar-btn:hover { background: var(--bg-base); color: var(--text-primary); } + +.company-selector { + display: inline-flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-white); + color: var(--text-secondary); +} + +.company-selector i { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.company-selector select { + min-width: 150px; + border: 0; + background: transparent; + color: var(--text-primary); + font-size: 13px; + outline: none; + font-family: inherit; +} + +.company-manage-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + transition: var(--transition); +} + +.company-manage-btn:hover { + background: var(--bg-base); + color: var(--text-primary); + transform: translateY(-1px); +} + +.company-manage-btn i { + width: 14px; + height: 14px; +} + +.company-manager { + display: flex; + flex-direction: column; + gap: 16px; +} + +.company-manager-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.company-manager-toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.company-manager-summary { + font-size: 13px; + color: var(--text-muted); +} + +.company-manager-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 420px; + overflow: auto; + padding-right: 2px; +} + +.company-manager-empty { + padding: 24px; + text-align: center; + color: var(--text-muted); + border: 1px dashed var(--border); + border-radius: var(--radius-md); + background: var(--bg-base); +} + +.company-manager-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fff; +} + +.company-manager-info { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.company-manager-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 0 4px rgba(0,0,0,0.03); +} + +.company-manager-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.company-manager-meta { + margin-top: 3px; + font-size: 12px; + color: var(--text-muted); +} + +.company-manager-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.company-manager-actions .btn-sm { + height: 32px; + padding: 0 10px; + gap: 6px; +} + +.company-manager-actions .btn-outline.danger { + border-color: rgba(245, 34, 45, 0.2); + color: #cf1322; +} + +.company-manager-actions .btn-outline.danger:hover { + background: rgba(245, 34, 45, 0.06); + border-color: rgba(245, 34, 45, 0.35); +} + +.company-manager-actions i { + width: 14px; + height: 14px; +} + +.company-color-pills { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.company-color-pill { + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0; + background: #fff; +} + +.company-color-pill span { + display: block; + width: 18px; + height: 18px; + margin: 4px auto; + border-radius: 50%; + background: var(--pill-color, #52c41a); +} + +.company-color-pill.selected { + border-color: var(--pill-color, #52c41a); + box-shadow: 0 0 0 3px rgba(82,196,26,.1); +} + +.company-delete-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.company-delete-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.company-delete-desc { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} + +@media (max-width: 720px) { + .company-manager-toolbar { + align-items: stretch; + flex-direction: column; + } + + .company-manager-toolbar-actions { + justify-content: stretch; + } + + .company-manager-toolbar-actions .btn { + flex: 1; + } + + .company-manager-item { + align-items: stretch; + flex-direction: column; + } + + .company-manager-actions { + justify-content: flex-end; + } +} + +.notif-dot { + position: absolute; + top: 6px; + right: 6px; + width: 7px; + height: 7px; + background: var(--danger); + border-radius: 50%; + border: 1px solid #fff; +} + +/* 通知下拉菜单 */ +.notif-dropdown-wrapper { + position: relative; +} + +.notif-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + background: var(--bg-white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + border: 1px solid var(--border); + z-index: 1000; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: all 0.2s ease; +} + +.notif-dropdown.open { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.notif-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.notif-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.notif-count { + font-size: 12px; + color: var(--text-muted); + background: var(--bg-base); + padding: 2px 8px; + border-radius: 10px; +} + +.notif-list { + max-height: 280px; + overflow-y: auto; +} + +.notif-item { + width: 100%; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + border-left: 0; + border-right: 0; + border-top: 0; + background: transparent; + cursor: pointer; + transition: background 0.15s; + text-align: left; + font-family: inherit; +} + +.notif-item:hover { + background: var(--bg-base); +} + +.notif-item.unread { + background: rgba(24,144,255,0.04); +} + +.notif-item.unread:hover { + background: rgba(24,144,255,0.08); +} + +.notif-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.notif-content { + flex: 1; + min-width: 0; +} + +.notif-text { + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + margin-bottom: 2px; +} + +.notif-item-tags, +.notif-detail-tags { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 5px; +} + +.notif-item-tags span, +.notif-tag, +.notif-severity { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1; + background: var(--bg-base); + color: var(--text-muted); +} + +.notif-item-tags span:last-child, +.notif-severity { + background: rgba(24,144,255,0.08); + color: #1677ff; +} + +.notif-item-tags span.critical, +.notif-severity.critical { + background: rgba(255,77,79,0.1); + color: #cf1322; +} + +.notif-item-tags span.warning, +.notif-severity.warning { + background: rgba(250,140,22,0.12); + color: #d46b08; +} + +.notif-item-tags span.success, +.notif-severity.success { + background: rgba(82,196,26,0.12); + color: #389e0d; +} + +.notif-time { + font-size: 12px; + color: var(--text-muted); +} + +.notif-footer { + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--border); +} + +.notif-footer a, +.notif-footer-action { + font-size: 13px; + color: var(--primary); + text-decoration: none; + background: transparent; + border: 0; + font-family: inherit; + cursor: pointer; +} + +.notif-footer a:hover, +.notif-footer-action:hover { + text-decoration: underline; +} + +.notif-footer-action:disabled { + color: var(--text-muted); + cursor: default; +} + +.notif-footer-action:disabled:hover { + text-decoration: none; +} + +.notif-footer-muted, +.notif-empty { + font-size: 13px; + color: var(--text-muted); +} + +.notif-empty { + padding: 24px 16px; + text-align: center; +} + +.notif-detail-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 16px; +} + +.notif-detail-meta div { + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-base); + min-width: 0; +} + +.notif-detail-meta span { + display: block; + margin-bottom: 4px; + color: var(--text-muted); + font-size: 11px; +} + +.notif-detail-meta strong { + display: block; + color: var(--text-primary); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.notification-principles { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.notification-principles div { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-base); +} + +.notification-principles strong, +.notification-principles span { + display: block; +} + +.notification-principles strong { + margin-bottom: 5px; + font-size: 13px; + color: var(--text-primary); +} + +.notification-principles span { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +.notification-rule-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.notification-rule-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; +} + +.notification-rule-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.notification-rule-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.notification-rule-desc, +.notification-rule-content { + margin-top: 5px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.notification-rule-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} + +.notification-rule-meta span { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-base); + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; +} + +.notification-rule-severity { + flex-shrink: 0; + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: rgba(24,144,255,0.08); + color: #1677ff; +} + +.notification-rule-severity.critical { + background: rgba(255,77,79,0.1); + color: #cf1322; +} + +.notification-rule-severity.warning { + background: rgba(250,140,22,0.12); + color: #d46b08; +} + +.notification-rule-severity.success { + background: rgba(82,196,26,0.12); + color: #389e0d; +} + +.notification-rule-controls { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +.notification-switch, +.notification-threshold, +.notification-channels, +.notification-channels label { + display: flex; + align-items: center; +} + +.notification-switch, +.notification-threshold { + gap: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.notification-switch input, +.notification-channels input { + accent-color: var(--primary); +} + +.notification-threshold em { + font-style: normal; + color: var(--text-muted); + font-size: 12px; +} + +.notification-threshold.readonly { + align-items: flex-start; + flex-direction: column; + gap: 4px; +} + +.notification-threshold.readonly strong { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; +} + +.notification-channels { + flex-wrap: wrap; + gap: 8px 12px; + font-size: 12px; + color: var(--text-muted); +} + +.notification-channels label { + gap: 5px; +} + +.tenant-selector { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); + background: var(--bg-white); +} + +.tenant-selector:hover { + border-color: var(--primary); + color: var(--primary); +} + +/* ========== PAGE CONTAINER ========== */ +.page-container { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.page-container.agent-config-page-container { + overflow: hidden; + padding: 0; + background: #f7faf7; +} + +.page-container.agent-config-page-container .page.active { + height: 100%; +} + +.page { display: none; } +.page.active { display: block; } + +/* ========== CARDS ========== */ +.card { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 20px; + box-shadow: var(--shadow-sm); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.card-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.card-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +/* ========== KPI CARDS ========== */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.kpi-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 20px; + position: relative; + overflow: hidden; + transition: var(--transition); + box-shadow: var(--shadow-sm); +} + +.kpi-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); + border-color: var(--primary-light); +} + +.kpi-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: var(--primary-gradient); +} + +.kpi-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.kpi-value { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + margin-bottom: 8px; + font-variant-numeric: tabular-nums; +} + +.kpi-change { + font-size: 12px; + display: flex; + align-items: center; + gap: 3px; +} + +.kpi-change.up { color: var(--success); } +.kpi-change.down { color: var(--danger); } + +.kpi-icon { + position: absolute; + right: 16px; + top: 16px; + width: 40px; + height: 40px; + border-radius: var(--radius); + background: var(--primary-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); +} + +/* ========== CHARTS GRID ========== */ +.charts-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.charts-grid .chart-card.full { grid-column: 1 / -1; } + +.chart-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 20px; + box-shadow: var(--shadow-sm); +} + +.chart-card canvas { max-height: 240px; } + +/* ========== BUTTONS ========== */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border-radius: var(--radius-sm); + font-size: 13.5px; + font-weight: 500; + transition: var(--transition); + cursor: pointer; + border: 1px solid transparent; + font-family: inherit; +} + +.btn-primary { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.btn-primary:hover { + background: var(--primary-dark); + border-color: var(--primary-dark); + box-shadow: var(--shadow-green); +} + +/* 绿色渐变按钮 - 商标色 */ +.btn-primary-green { + background: linear-gradient(135deg, #73d13d 0%, #389e0d 100%); + color: #fff; + border: none; + box-shadow: 0 2px 8px rgba(82, 196, 26, 0.3); +} + +.btn-primary-green:hover { + background: linear-gradient(135deg, #52c41a 0%, #237804 100%); + box-shadow: 0 4px 16px rgba(82, 196, 26, 0.4); + transform: translateY(-1px); +} + +.btn-primary-green:active { + transform: translateY(0); +} + +.btn-outline { + background: transparent; + border-color: var(--border-dark); + color: var(--text-primary); +} + +.btn-outline:hover { + border-color: var(--primary); + color: var(--primary); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); +} + +.btn-ghost:hover { background: var(--bg-base); } + +.btn-danger { + background: var(--danger); + color: #fff; + border-color: var(--danger); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-lg { + padding: 10px 24px; + font-size: 15px; +} + +.btn i { width: 15px; height: 15px; } + +/* ========== TABLES ========== */ +.table-wrapper { + overflow-x: auto; + border-radius: var(--radius); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} + +thead th { + background: #fafafa; + padding: 10px 14px; + text-align: left; + font-weight: 600; + color: var(--text-secondary); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +tbody td { + padding: 11px 14px; + border-bottom: 1px solid var(--border); + color: var(--text-primary); + vertical-align: middle; +} + +tbody tr:last-child td { border-bottom: none; } + +tbody tr:hover td { background: #fafcff; } + +/* ========== BADGES ========== */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 20px; + font-size: 11.5px; + font-weight: 500; + white-space: nowrap; +} + +.badge-success { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; } +.badge-warning { background: #fffbe6; color: #faad14; border: 1px solid #ffe58f; } +.badge-danger { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; } +.badge-info { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; } +.badge-default { background: #fafafa; color: #595959; border: 1px solid #d9d9d9; } +.badge-purple { background: #f9f0ff; color: #722ed1; border: 1px solid #d3adf7; } + +/* ========== FORMS ========== */ +.form-group { + margin-bottom: 18px; +} + +.form-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 6px; +} + +.form-label .required { color: var(--danger); margin-left: 2px; } + +.form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-dark); + border-radius: var(--radius-sm); + font-size: 13.5px; + font-family: inherit; + color: var(--text-primary); + background: var(--bg-white); + transition: var(--transition); + outline: none; +} + +.form-control:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(82,196,26,0.1); +} + +.form-control::placeholder { color: var(--text-placeholder); } + +textarea.form-control { + resize: vertical; + min-height: 80px; + line-height: 1.6; +} + +select.form-control { appearance: none; cursor: pointer; } + +.form-hint { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +/* ========== TOGGLE / SWITCH ========== */ +.toggle-switch { + position: relative; + width: 40px; + height: 22px; + flex-shrink: 0; +} + +.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; } + +.toggle-track { + position: absolute; + inset: 0; + background: #d9d9d9; + border-radius: 11px; + cursor: pointer; + transition: background 0.2s; +} + +.toggle-track::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); +} + +.toggle-switch input:checked + .toggle-track { background: var(--primary); } +.toggle-switch input:checked + .toggle-track::after { transform: translateX(18px); } + +/* ========== PAGE HEADER ========== */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; + gap: 16px; +} + +.page-title { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.page-desc { + font-size: 13px; + color: var(--text-muted); + margin-top: 4px; +} + +.page-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +/* ========== FILTERS ========== */ +.filter-bar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.search-input { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border: 1px solid var(--border-dark); + border-radius: var(--radius-sm); + background: var(--bg-white); + flex: 1; + max-width: 320px; + transition: var(--transition); +} + +.search-input:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(82,196,26,0.1); +} + +.search-input i { color: var(--text-muted); flex-shrink: 0; } + +.search-input input { + border: none; + outline: none; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + font-family: inherit; +} + +.filter-select { + padding: 7px 12px; + border: 1px solid var(--border-dark); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-primary); + background: var(--bg-white); + cursor: pointer; + outline: none; + font-family: inherit; + transition: var(--transition); +} + +.filter-select:focus { border-color: var(--primary); } + +/* ========== SESSION DRILLDOWN ========== */ +.session-drilldown-banner { + display: flex; + align-items: center; + gap: 12px; + margin: -8px 0 16px; + padding: 14px 16px; + border: 1px solid #d9f7be; + border-radius: var(--radius-lg); + background: linear-gradient(135deg, #f6ffed 0%, #ffffff 100%); + box-shadow: var(--shadow-sm); +} + +.session-drilldown-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.session-drilldown-icon i { + width: 18px; + height: 18px; +} + +.session-drilldown-copy { + flex: 1; + min-width: 0; +} + +.session-drilldown-copy strong { + display: block; + font-size: 14px; + margin-bottom: 3px; +} + +.session-drilldown-copy span { + display: block; + font-size: 12px; + color: var(--text-muted); +} + +.session-panel-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.session-view-backbar { + display: flex; + justify-content: flex-start; + margin: -4px 0 12px; +} + +.session-detail-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 16px; + align-items: start; +} + +.session-filter-panel { + display: flex; + justify-content: space-between; + align-items: stretch; + gap: 16px; + margin-bottom: 16px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-card); + box-shadow: var(--shadow-sm); +} + +.session-filter-main { + display: grid; + grid-template-columns: 180px 220px; + gap: 14px; + align-items: end; +} + +.session-filter-main .form-group { + margin-bottom: 0; +} + +.session-filter-stats { + display: grid; + grid-template-columns: repeat(3, 96px); + gap: 10px; +} + +.session-filter-stats > div, +.session-topic-summary > div { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fafafa; +} + +.session-filter-stats span, +.session-topic-summary span { + display: block; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.session-filter-stats strong, +.session-topic-summary strong { + display: block; + font-size: 20px; + color: var(--text-primary); + line-height: 1; +} + +.session-device-layout { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr); + gap: 16px; + align-items: start; +} + +.session-device-row { + cursor: pointer; +} + +.session-device-row.active td { + background: var(--primary-bg); +} + +.session-device-panel { + min-height: 420px; +} + +.session-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.session-panel-header h3 { + font-size: 16px; + margin: 0 0 4px; +} + +.session-panel-header p { + font-size: 12px; + color: var(--text-muted); +} + +.session-history-list, +.session-aggregate-list { + display: grid; + gap: 12px; +} + +.session-history-card, +.session-aggregate-card { + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; +} + +.session-history-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +.session-history-head strong { + display: block; + font-size: 14px; + margin-bottom: 4px; +} + +.session-history-head span:not(.badge) { + display: block; + font-size: 12px; + color: var(--text-muted); +} + +.session-transcript { + display: grid; + gap: 10px; + padding: 12px; + border-radius: var(--radius); + background: #fafafa; +} + +.session-message { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.session-message.user { + justify-content: flex-end; +} + +.session-message.user .session-message-avatar { + order: 2; + background: #e6f7ff; + color: #1890ff; +} + +.session-message.bot .session-message-avatar { + background: var(--primary); + color: #fff; +} + +.session-message-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 11px; + font-weight: 600; +} + +.session-message-text { + max-width: 78%; + padding: 9px 12px; + border-radius: 12px; + font-size: 13px; + line-height: 1.55; + color: var(--text-primary); + background: #fff; + border: 1px solid var(--border); +} + +.session-message.user .session-message-text { + color: #fff; + border-color: var(--primary); + background: var(--primary); + border-bottom-right-radius: 4px; +} + +.session-message.bot .session-message-text { + border-bottom-left-radius: 4px; +} + +.session-more { + text-align: center; + padding: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.session-topic-layout { + display: grid; + gap: 16px; +} + +.session-topic-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.chat-preview { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + padding: 10px; + border-radius: var(--radius); + background: #fafafa; +} + +.chat-preview .chat-bubble { + display: flex; + align-items: flex-start; + gap: 8px; + max-width: 100%; + padding: 0; + border-radius: 0; +} + +.chat-preview .chat-bubble.user { + justify-content: flex-end; +} + +.chat-preview .chat-bubble.user .bubble-avatar { + order: 2; +} + +.bubble-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 12px; + font-weight: 600; +} + +.bubble-text { + max-width: 78%; + padding: 10px 12px; + border-radius: 12px; + font-size: 13px; + line-height: 1.55; + color: var(--text-primary); + background: var(--bg-base); +} + +.chat-preview .chat-bubble.user .bubble-text { + color: #fff; + background: var(--primary); + border-bottom-right-radius: 4px; +} + +.chat-preview .chat-bubble.bot .bubble-text { + border-bottom-left-radius: 4px; +} + +/* ========== TABS ========== */ +.tabs { + display: flex; + border-bottom: 2px solid var(--border); + margin-bottom: 20px; + gap: 0; +} + +.tab-item { + padding: 10px 20px; + font-size: 13.5px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + position: relative; + transition: var(--transition); + white-space: nowrap; +} + +.tab-item:hover { color: var(--primary); } + +.tab-item.active { + color: var(--primary); + font-weight: 600; +} + +.tab-item.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; right: 0; + height: 2px; + background: var(--primary); + border-radius: 1px; +} + +.tab-content { display: none; } +.tab-content.active { display: block; } + +/* ========== MODAL ========== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} + +.modal-overlay.open { + opacity: 1; + pointer-events: all; +} + +.modal { + background: var(--bg-white); + border-radius: var(--radius-xl); + padding: 28px; + width: 560px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + transform: scale(0.95) translateY(10px); + transition: transform 0.2s; +} + +.modal-overlay.open .modal { transform: scale(1) translateY(0); } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-title { + font-size: 17px; + font-weight: 700; +} + +.modal-close { + color: var(--text-muted); + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.modal-close:hover { background: var(--bg-base); color: var(--text-primary); } + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +/* 弹窗尺寸变体 */ +.modal.modal-sm { width: 400px; } +.modal.modal-lg { width: 720px; } +.modal.modal-xl { width: 900px; } +.modal.modal-full { width: 95vw; max-width: 1200px; } + +/* ========== EMPTY STATE ========== */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.empty-state i { + width: 48px; + height: 48px; + margin: 0 auto 16px; + display: block; + color: var(--text-placeholder); +} + +.empty-state p { font-size: 14px; } + +/* 空状态插画风格 */ +.empty-state-illustration { + text-align: center; + padding: 40px 20px; +} + +.empty-state-icon { + width: 64px; + height: 64px; + margin: 0 auto 16px; + background: var(--bg-base); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-placeholder); +} + +.empty-state-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.empty-state-desc { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 20px; +} + +/* ========== STATUS DOTS ========== */ +.status-dot { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.status-dot::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.status-dot.online { color: var(--success); } +.status-dot.offline { color: var(--text-muted); } +.status-dot.warning { color: var(--warning); } +.status-dot.error { color: var(--danger); } + +/* ========== PROGRESS BAR ========== */ +.progress-bar { + background: var(--border); + border-radius: 4px; + height: 6px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--primary-gradient); + border-radius: 4px; + transition: width 0.6s ease; +} + +/* ========== SECTION DIVIDER ========== */ +.section-divider { + border: none; + border-top: 1px solid var(--border); + margin: 20px 0; +} + +/* ========== TOOLTIP ========== */ +[data-tooltip] { position: relative; display: inline-flex; align-items: center; justify-content: center; } + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: #1a1a1a; + color: #fff; + font-size: 12px; + padding: 5px 10px; + border-radius: 6px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s, transform 0.15s; + z-index: 9999; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + line-height: 1.4; +} + +[data-tooltip]:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(-2px); +} + +/* 表格操作按钮专用 tooltip */ +.kb-table-actions [data-tooltip]::after { + bottom: calc(100% + 4px); +} + +/* 防止 tooltip 被遮挡 */ +.dropdown-menu + [data-tooltip]::after { + display: none; +} + +/* ========== RESPONSIVE ========== */ +@media (max-width: 1024px) { + .charts-grid { grid-template-columns: 1fr; } + .config-panels { grid-template-columns: 1fr; } + .session-detail-layout { grid-template-columns: 1fr; } + .session-device-layout { grid-template-columns: 1fr; } + .session-filter-panel { flex-direction: column; } + .session-filter-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .notification-principles { grid-template-columns: 1fr; } + .notification-rule-row { grid-template-columns: 1fr; } + .notif-detail-meta { grid-template-columns: 1fr; } + #replayPanel { position: relative !important; } +} + +@media (max-width: 768px) { + .sidebar { + position: fixed; + left: 0; top: 0; bottom: 0; + z-index: 200; + transform: translateX(-100%); + transition: transform 0.25s ease; + } + .sidebar.mobile-open { transform: translateX(0); } + .form-row { grid-template-columns: 1fr; } + .kpi-grid { grid-template-columns: repeat(2, 1fr); } + .session-drilldown-banner { + align-items: flex-start; + flex-direction: column; + } + .session-panel-actions { + width: 100%; + align-items: stretch; + flex-direction: column; + } + .session-panel-actions .btn { + width: 100%; + justify-content: center; + } + .session-filter-main, + .session-topic-summary { + grid-template-columns: 1fr; + } + .session-filter-stats { + grid-template-columns: 1fr; + } + .session-message-text { + max-width: 82%; + } +} + +@media (max-width: 768px) { + .kpi-grid { grid-template-columns: 1fr; } +} + +/* ========== ANIMATIONS ========== */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.page.active { animation: fadeIn 0.2s ease; } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.loading-pulse { animation: pulse 1.5s ease-in-out infinite; } + +/* ========== NOTIFICATION TOAST ========== */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: #fff; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + border-left: 4px solid var(--primary); + min-width: 280px; + animation: slideIn 0.25s ease; +} + +.toast.success { border-color: var(--success); } +.toast.error { border-color: var(--danger); } +.toast.warning { border-color: var(--warning); } + +@keyframes slideIn { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} + +/* ========== COLLAPSE ========== */ +.collapse-trigger { cursor: pointer; user-select: none; } +.collapse-content { display: none; } +.collapse-content.open { display: block; } + +/* ========== SKELETON LOADING ========== */ +.skeleton { + background: linear-gradient(90deg, var(--bg-base) 25%, #e8e8e8 50%, var(--bg-base) 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s ease-in-out infinite; + border-radius: var(--radius-sm); +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton-text { + height: 14px; + margin-bottom: 8px; +} + +.skeleton-text:last-child { + width: 60%; +} + +.skeleton-title { + height: 24px; + width: 40%; + margin-bottom: 16px; +} + +.skeleton-avatar { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.skeleton-card { + height: 120px; + margin-bottom: 16px; +} + +/* ========================================== + 知识库管理模块样式 + ========================================== */ + +/* 返回按钮 */ +.page-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-white); + cursor: pointer; + transition: var(--transition); +} + +.page-back-btn:hover { + border-color: var(--primary); + color: var(--primary); +} + +/* 知识库列表页 */ +.kb-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.kb-list-title { + display: flex; + align-items: center; + gap: 12px; +} + +.kb-list-stats { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--primary-bg); + border-radius: 20px; + font-size: 12px; + color: var(--primary-dark); +} + +.kb-list-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* 知识库表格 */ +.kb-table-wrapper { + background: var(--bg-white); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + overflow: hidden; +} + +.kb-table { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} + +.kb-table thead { + background: #fafafa; +} + +.kb-table thead th { + padding: 12px 16px; + text-align: left; + font-weight: 600; + color: var(--text-secondary); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.kb-table thead th.sortable { + cursor: pointer; + user-select: none; +} + +.kb-table thead th.sortable:hover { + color: var(--primary); +} + +.kb-table thead th .sort-icon { + margin-left: 4px; + opacity: 0.4; +} + +.kb-table thead th.sorted .sort-icon { + opacity: 1; + color: var(--primary); +} + +.kb-table tbody td { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + color: var(--text-primary); + vertical-align: middle; +} + +.kb-table tbody tr:last-child td { + border-bottom: none; +} + +.kb-table tbody tr:hover td { + background: #fafcff; +} + +.kb-table tbody tr.selected td { + background: rgba(82, 196, 26, 0.05); +} + +/* 表格复选框 */ +.kb-table .row-checkbox { + width: 18px; + height: 18px; + accent-color: var(--primary); + cursor: pointer; +} + +/* 知识库名称单元格 */ +.kb-name-cell { + display: flex; + align-items: center; + gap: 10px; +} + +.kb-name-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: var(--primary-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); + flex-shrink: 0; +} + +.kb-name-text { + font-weight: 500; + color: var(--text-primary); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-name-desc { + font-size: 12px; + color: var(--text-muted); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ID单元格 */ +.kb-id-cell { + font-family: 'SF Mono', Monaco, monospace; + font-size: 12px; + color: var(--text-muted); +} + +/* 标签列表 */ +.kb-tags-cell { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +/* 表格操作 */ +.kb-table-actions { + display: flex; + align-items: center; + gap: 4px; +} + +/* 分页器 */ +.kb-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-top: 1px solid var(--border); + background: #fafafa; +} + +.kb-pagination-info { + font-size: 13px; + color: var(--text-secondary); +} + +.kb-pagination-controls { + display: flex; + align-items: center; + gap: 4px; +} + +.kb-pagination-btn { + width: 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-white); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--transition); + font-size: 13px; +} + +.kb-pagination-btn:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); +} + +.kb-pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.kb-pagination-btn.active { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +/* 批量操作栏 */ +.kb-batch-bar { + display: none; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--primary-bg); + border-radius: var(--radius); + margin-bottom: 16px; + animation: slideDown 0.2s ease; +} + +.kb-batch-bar.visible { + display: flex; +} + +.kb-batch-info { + flex: 1; + font-size: 13px; + color: var(--primary-dark); +} + +.kb-batch-actions { + display: flex; + gap: 8px; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 知识库类型标签 */ +.kb-type-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.kb-type-tag.doc_search { background: #e6f7ff; color: #1890ff; } +.kb-type-tag.data_query { background: #fff7e6; color: #fa8c16; } +.kb-type-tag.image_qa { background: #f9f0ff; color: #722ed1; } +.kb-type-tag.video_search { background: #fff1f0; color: #ff4d4f; } + +/* 状态徽章 */ +.kb-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.kb-status-badge.ready { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; } +.kb-status-badge.processing { background: #fffbe6; color: #faad14; border: 1px solid #ffe58f; } +.kb-status-badge.error { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; } +.kb-status-badge.uploading { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; } + +.kb-status-badge .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.kb-status-badge.processing .status-dot, +.kb-status-badge.uploading .status-dot { + animation: pulse 1s ease-in-out infinite; +} + +/* 加载状态 */ +.kb-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.kb-loading-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 12px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* 空状态 */ +.kb-empty { + text-align: center; + padding: 80px 20px; +} + +.kb-empty-icon { + width: 80px; + height: 80px; + margin: 0 auto 24px; + background: var(--bg-base); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-placeholder); +} + +.kb-empty-icon i { + width: 36px; + height: 36px; +} + +.kb-empty-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.kb-empty-desc { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 24px; +} + +/* 筛选器样式 */ +.filter-tabs { + display: flex; + gap: 4px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; +} + +.filter-tab { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.filter-tab.active { + background: var(--primary); + color: #fff; +} + +.filter-tab:hover:not(.active) { + background: var(--bg-base); + color: var(--text-primary); +} + +.filter-count { + background: rgba(0,0,0,0.1); + border-radius: 10px; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + min-width: 18px; + text-align: center; +} + +.filter-tab.active .filter-count { + background: rgba(255,255,255,0.25); +} + +.filter-search { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 12px; + min-width: 220px; +} + +.filter-search input { + border: none; + outline: none; + background: transparent; + font-size: 13px; + color: var(--text-primary); + width: 100%; +} + +/* ========== 知识库选择弹窗 ========== */ +.kb-select-modal { + width: 720px; + max-width: 95vw; +} + +.kb-select-modal-header { + margin-bottom: 16px; +} + +.kb-select-modal-filters { + display: flex; + flex-direction: column; + gap: 12px; +} + +.kb-select-type-filter { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.kb-select-type-btn { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 16px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-white); + cursor: pointer; + transition: var(--transition); +} + +.kb-select-type-btn:hover { + border-color: var(--primary-light); + color: var(--primary); +} + +.kb-select-type-btn.active { + border-color: var(--primary); + background: var(--primary-bg); + color: var(--primary); +} + +.kb-select-list-area { + max-height: 400px; + overflow-y: auto; + margin: 0 -28px; + padding: 0 28px; +} + +.kb-select-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 0; + border-bottom: 1px solid var(--border); + transition: var(--transition); +} + +.kb-select-item:last-child { + border-bottom: none; +} + +.kb-select-item:hover { + background: var(--bg-base); + margin: 0 -28px; + padding: 14px 28px; +} + +.kb-select-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + background: var(--primary-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); + flex-shrink: 0; +} + +.kb-select-info { + flex: 1; + min-width: 0; +} + +.kb-select-name { + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.kb-select-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + flex-wrap: wrap; +} + +.kb-select-tags { + display: flex; + gap: 4px; + margin-top: 6px; +} + +.kb-select-action { + flex-shrink: 0; +} + +.kb-select-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.kb-select-info-summary { + font-size: 13px; + color: var(--text-muted); +} + +.kb-create-link-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--primary); + cursor: pointer; + transition: var(--transition); +} + +.kb-create-link-btn:hover { + text-decoration: underline; +} + +/* ========== 知识库创建向导 ========== */ +.kb-create-wizard { + max-width: 960px; + margin: 0 auto; +} + +.wizard-steps { + display: flex; + align-items: center; + justify-content: center; + margin: 32px 0; + padding: 0 40px; +} + +.wizard-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.wizard-step-num { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bg-base); + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + color: var(--text-muted); + transition: var(--transition); +} + +.wizard-step.active .wizard-step-num { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.wizard-step.completed .wizard-step-num { + background: var(--success); + border-color: var(--success); + color: #fff; +} + +.wizard-step-label { + font-size: 13px; + color: var(--text-muted); + white-space: nowrap; +} + +.wizard-step.active .wizard-step-label { + color: var(--primary); + font-weight: 500; +} + +.wizard-step.completed .wizard-step-label { + color: var(--success); +} + +.wizard-step-line { + flex: 1; + height: 2px; + background: var(--border); + margin: 0 24px; + max-width: 120px; +} + +.wizard-step-line.completed { + background: var(--success); +} + +.wizard-content { + background: var(--bg-white); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 32px; + margin-bottom: 24px; +} + +.wizard-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 24px; + background: var(--bg-white); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + position: sticky; + bottom: 24px; +} + +.wizard-footer-left, +.wizard-footer-right { + display: flex; + gap: 12px; +} + +/* 知识库类型选择卡片 */ +.kb-type-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.kb-type-card { + position: relative; + padding: 20px; + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); +} + +.kb-type-card:hover { + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.kb-type-card.selected { + border-color: var(--primary); + background: var(--primary-bg); +} + +.kb-type-card .check-icon { + position: absolute; + top: 12px; + right: 12px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.5); + transition: var(--transition); +} + +.kb-type-card.selected .check-icon { + opacity: 1; + transform: scale(1); +} + +.kb-type-card-icon { + width: 48px; + height: 48px; + border-radius: var(--radius); + background: var(--bg-white); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); + margin-bottom: 12px; + box-shadow: var(--shadow-sm); +} + +.kb-type-card-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.kb-type-card-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +/* 帮助提示链接 */ +.form-help-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--info); + cursor: pointer; + margin-top: 12px; +} + +.form-help-link:hover { + text-decoration: underline; +} + +/* ========== 数据源相关 ========== */ +.data-source-tabs { + display: flex; + gap: 4px; + background: var(--bg-base); + border-radius: var(--radius); + padding: 4px; + margin-bottom: 20px; +} + +.data-source-tab { + flex: 1; + padding: 10px 16px; + border-radius: 6px; + font-size: 13px; + color: var(--text-secondary); + text-align: center; + cursor: pointer; + transition: var(--transition); +} + +.data-source-tab:hover { + color: var(--primary); +} + +.data-source-tab.active { + background: var(--bg-white); + color: var(--primary); + font-weight: 500; + box-shadow: var(--shadow-sm); +} + +/* 上传区域 */ +.upload-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px 20px; + text-align: center; + transition: var(--transition); + background: var(--bg-base); +} + +.upload-zone:hover, +.upload-zone.dragover { + border-color: var(--primary); + background: var(--primary-bg); +} + +.upload-zone-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.upload-zone-text { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.upload-zone-hint { + font-size: 12px; + color: var(--text-muted); + margin-top: 16px; +} + +/* 文件列表 */ +.file-list { + margin-top: 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.file-list-header { + display: flex; + align-items: center; + padding: 12px 16px; + background: #fafafa; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; + border-bottom: 1px solid var(--border); +} + +.file-list-item { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + transition: var(--transition); +} + +.file-list-item:last-child { + border-bottom: none; +} + +.file-list-item:hover { + background: var(--bg-base); +} + +.file-icon { + font-size: 24px; + margin-right: 12px; + flex-shrink: 0; +} + +.file-info { + flex: 1; + min-width: 0; +} + +.file-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-meta { + font-size: 12px; + color: var(--text-muted); +} + +.file-actions { + display: flex; + gap: 4px; +} + +/* 文件状态 */ +.file-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + min-width: 80px; +} + +.file-status.pending { color: var(--text-muted); } +.file-status.uploading { color: var(--info); } +.file-status.ready { color: var(--success); } +.file-status.error { color: var(--danger); } +.file-status.processing { color: var(--warning); } + +.file-status .spinner { + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* 类目选择器 */ +.category-selector { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +/* 自动同步开关 */ +.sync-switch { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: var(--bg-base); + border-radius: var(--radius); + margin-bottom: 20px; +} + +.sync-switch-label { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.sync-switch-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +/* Switch开关 */ +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch-slider { + position: absolute; + cursor: pointer; + inset: 0; + background: #d9d9d9; + border-radius: 12px; + transition: var(--transition); +} + +.switch-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: var(--transition); + box-shadow: 0 1px 4px rgba(0,0,0,0.15); +} + +.switch input:checked + .switch-slider { + background: var(--primary); +} + +.switch input:checked + .switch-slider::before { + transform: translateX(20px); +} + +/* 文件对比面板 */ +.file-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.file-compare-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.file-compare-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #fafafa; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); +} + +.file-compare-body { + padding: 12px; + max-height: 250px; + overflow-y: auto; +} + +.file-compare-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition); +} + +.file-compare-item:hover { + background: var(--bg-base); +} + +.file-compare-item.selected { + background: var(--primary-bg); + border: 1px solid var(--primary-light); +} + +.file-compare-empty { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); + font-size: 13px; +} + +/* 解析方式选择器 */ +.parse-method-selector { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.parse-method-option { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + cursor: pointer; + transition: var(--transition); +} + +.parse-method-option:hover { + border-color: var(--primary-light); +} + +.parse-method-option.selected { + border-color: var(--primary); + background: var(--primary-bg); + color: var(--primary); +} + +/* ========== 索引设置相关 ========== */ +/* 切片配置面板 */ +.slice-config-panel { + padding: 16px; + background: var(--bg-base); + border-radius: var(--radius); + margin-bottom: 20px; +} + +.slice-config-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.slice-config-row:last-child { + border-bottom: none; +} + +.slice-config-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); +} + +.slice-config-control { + display: flex; + align-items: center; + gap: 8px; +} + +/* 范围滑块 */ +.range-slider { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.range-slider input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 2px; + outline: none; +} + +.range-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); +} + +.range-slider-value { + min-width: 40px; + text-align: center; + font-weight: 600; + color: var(--primary); +} + +.range-slider-hint { + font-size: 12px; + color: var(--text-muted); + min-width: 60px; +} + +/* Help Tooltip */ +.help-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-base); + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + margin-left: 4px; + transition: var(--transition); +} + +.help-tip:hover { + background: var(--primary); + color: #fff; +} + +/* 字符计数器 */ +.char-counter { + font-size: 12px; + color: var(--text-muted); + text-align: right; + margin-top: 4px; +} + +.char-counter.warning { + color: var(--warning); +} + +/* 高级参数折叠面板 */ +.advanced-config { + margin-top: 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.advanced-config-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #fafafa; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); +} + +.advanced-config-header:hover { + background: var(--bg-base); +} + +.advanced-config-header i { + transition: transform 0.2s; +} + +.advanced-config.open .advanced-config-header i { + transform: rotate(180deg); +} + +.advanced-config-body { + display: none; + padding: 16px; + background: var(--bg-white); +} + +.advanced-config.open .advanced-config-body { + display: block; +} + +/* ========== 知识库详情页 ========== */ +.kb-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 24px; + background: var(--bg-white); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + margin-bottom: 20px; +} + +.kb-detail-info { + flex: 1; +} + +.kb-detail-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.kb-detail-desc { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.kb-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 12px; + color: var(--text-muted); +} + +.kb-detail-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +.kb-detail-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +/* 详情Tab */ +.kb-detail-tabs { + display: flex; + gap: 4px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; + margin-bottom: 20px; +} + +.kb-detail-tab { + flex: 1; + padding: 10px 20px; + border-radius: 6px; + font-size: 13px; + color: var(--text-secondary); + text-align: center; + cursor: pointer; + transition: var(--transition); +} + +.kb-detail-tab:hover { + color: var(--primary); +} + +.kb-detail-tab.active { + background: var(--primary); + color: #fff; +} + +/* ========== 切片相关 ========== */ +.slice-list { + max-height: 400px; + overflow-y: auto; +} + +.slice-list-item { + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 12px; + transition: var(--transition); +} + +.slice-list-item:hover { + border-color: var(--primary-light); +} + +.slice-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.slice-list-content { + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); + background: var(--bg-base); + padding: 12px; + border-radius: var(--radius-sm); +} + +/* 切片编辑 */ +.slice-edit-content textarea { + font-family: 'SF Mono', Monaco, monospace; + font-size: 13px; + line-height: 1.6; +} + +.slice-edit-footer { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); + margin-top: 8px; +} + +/* ========== Meta信息表格 ========== */ +.meta-info-table { + width: 100%; + border-collapse: collapse; +} + +.meta-info-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.meta-info-table td:first-child { + width: 100px; + color: var(--text-muted); + font-weight: 500; +} + +.meta-info-table tr:last-child td { + border-bottom: none; +} + +/* ========== 标签相关 ========== */ +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: var(--primary-bg); + border: 1px solid var(--primary-light); + border-radius: 16px; + font-size: 12px; + color: var(--primary-dark); +} + +.tag-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--primary-bg); + border: 1px solid var(--primary-light); + border-radius: 16px; + font-size: 13px; + color: var(--primary-dark); +} + +.remove-tag { + cursor: pointer; + opacity: 0.6; + transition: var(--transition); +} + +.remove-tag:hover { + opacity: 1; + color: var(--danger); +} + +.add-tag-input { + display: inline-flex; + align-items: center; +} + +.add-tag-input input { + padding: 6px 12px; + border: 1px dashed var(--border); + border-radius: 16px; + font-size: 13px; + outline: none; + width: 120px; + transition: var(--transition); +} + +.add-tag-input input:focus { + border-color: var(--primary); + border-style: solid; +} + +/* ========== 命中测试 ========== */ +.hit-test-panel { + padding: 24px; + background: var(--bg-base); + border-radius: var(--radius); +} + +.hit-test-query { + display: flex; + gap: 12px; + margin-bottom: 20px; +} + +.hit-test-query input { + flex: 1; +} + +.hit-test-results { + max-height: 350px; + overflow-y: auto; +} + +.hit-test-result-item { + padding: 16px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 12px; +} + +.hit-test-result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.hit-test-result-source { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.hit-test-result-meta { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 8px; +} + +.hit-test-result-content { + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); + background: var(--bg-base); + padding: 12px; + border-radius: var(--radius-sm); + max-height: 100px; + overflow: hidden; +} + +.hit-test-result-content.expanded { + max-height: none; +} + +/* 历史记录 */ +.history-record { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.history-record-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #fafafa; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); +} + +.history-record-header:hover { + background: var(--bg-base); +} + +.history-record-body { + display: none; + max-height: 200px; + overflow-y: auto; +} + +.history-record.open .history-record-body { + display: block; +} + +.history-record.open .history-record-header i { + transform: rotate(180deg); +} + +.history-record-item { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: var(--transition); +} + +.history-record-item:last-child { + border-bottom: none; +} + +.history-record-item:hover { + background: var(--bg-base); +} + +.history-record-time { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.history-record-query { + font-size: 13px; + color: var(--text-primary); + margin-bottom: 2px; +} + +.history-record-result { + font-size: 12px; + color: var(--text-secondary); +} + +/* 快捷问题标签 */ +.quick-questions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.quick-question-tag { + padding: 6px 12px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 16px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); +} + +.quick-question-tag:hover { + border-color: var(--primary); + color: var(--primary); +} + +/* ========== 下拉菜单 ========== */ +.dropdown { + position: relative; +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + min-width: 140px; + z-index: 100; + opacity: 0; + visibility: hidden; + transform: translateY(-4px); + transition: all 0.15s ease; +} + +.dropdown.open .dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); +} + +.dropdown-item:hover { + background: var(--bg-base); + color: var(--text-primary); +} + +.dropdown-item.danger { + color: var(--danger); +} + +.dropdown-item.danger:hover { + background: #fff2f0; +} + +.dropdown-divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +/* ========== 确认弹窗 ========== */ +.confirm-leave-modal p { + margin-bottom: 8px; + font-size: 14px; + color: var(--text-primary); +} + +.confirm-leave-modal .warning-text { + color: var(--warning); + font-size: 13px; +} + +/* ========== API说明卡片 ========== */ +.api-note { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: var(--radius-sm); + font-size: 12.5px; + color: #ad8b00; + margin-top: 8px; +} + +.api-note i { + color: #faad14; + flex-shrink: 0; + margin-top: 1px; +} + +/* ========== OTA 最新版本确认流 ========== */ +.ota-latest-flow { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ota-latest-hero { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.ota-latest-icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: #f6ffed; + color: var(--primary); + border: 1px solid rgba(82,196,26,0.16); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ota-latest-icon svg { + width: 22px; + height: 22px; +} + +.ota-latest-title { + font-size: 15px; + line-height: 1.35; + font-weight: 700; + color: var(--text-primary); +} + +.ota-latest-desc { + margin-top: 5px; + font-size: 13px; + line-height: 1.55; + color: var(--text-muted); +} + +.ota-version-compare { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px; + align-items: center; +} + +.ota-version-box { + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-base); +} + +.ota-version-box.target { + background: #f6ffed; + border-color: rgba(82,196,26,0.22); +} + +.ota-version-box span { + display: block; + margin-bottom: 5px; + color: var(--text-muted); + font-size: 12px; +} + +.ota-version-box strong { + color: var(--text-primary); + font-size: 14px; +} + +.ota-version-box.target strong { + color: var(--primary); +} + +.ota-version-arrow { + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; +} + +.ota-version-arrow svg { + width: 18px; + height: 18px; +} + +.ota-latest-section { + padding-top: 2px; +} + +.ota-latest-section-title { + margin-bottom: 9px; + color: var(--text-primary); + font-size: 13px; + font-weight: 700; +} + +.ota-upgrade-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ota-upgrade-option { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-white); + cursor: pointer; + transition: var(--transition); +} + +.ota-upgrade-option:hover { + border-color: rgba(82,196,26,0.28); + background: #fbfffa; +} + +.ota-upgrade-option.active { + border-color: rgba(82,196,26,0.42); + background: #f6ffed; + box-shadow: inset 0 0 0 1px rgba(82,196,26,0.08); +} + +.ota-upgrade-option input { + margin-top: 2px; + accent-color: var(--primary); +} + +.ota-upgrade-option strong { + display: block; + color: var(--text-primary); + font-size: 13px; + line-height: 1.25; +} + +.ota-upgrade-option small { + display: block; + margin-top: 5px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} + +.ota-latest-task-config { + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-base); +} + +.ota-upgrade-preview { + min-height: 40px; + padding: 10px 12px; + border: 1px solid rgba(82,196,26,0.18); + border-radius: 10px; + background: #f6ffed; + color: var(--primary); + font-size: 14px; + font-weight: 700; +} + +@media (max-width: 640px) { + .ota-version-compare, + .ota-upgrade-options { + grid-template-columns: 1fr; + } + + .ota-version-arrow { + transform: rotate(90deg); + } +} + +/* ========== 错误提示 ========== */ +.error-tip { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--danger); + margin-top: 4px; +} + +/* ========== 解析进度 ========== */ +.parse-progress { + margin-top: 16px; +} + +.parse-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.parse-progress-bar { + height: 6px; + background: var(--border); + border-radius: 3px; + overflow: hidden; +} + +.parse-progress-fill { + height: 100%; + background: var(--primary-gradient); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* ========== 保存草稿提示 ========== */ +.save-draft-hint { + font-size: 12px; + color: var(--text-muted); + margin-top: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +/* ========== 类目管理抽屉 ========== */ +.drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} + +.drawer-overlay.open { + opacity: 1; + pointer-events: all; +} + +.drawer { + position: fixed; + top: 0; + right: 0; + width: 480px; + max-width: 90vw; + height: 100vh; + background: var(--bg-white); + box-shadow: var(--shadow-lg); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 1001; + display: flex; + flex-direction: column; +} + +.drawer.open { + transform: translateX(0); +} + +.drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.drawer-title { + font-size: 16px; + font-weight: 600; +} + +.drawer-close { + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + cursor: pointer; + transition: var(--transition); +} + +.drawer-close:hover { + background: var(--bg-base); + color: var(--text-primary); +} + +.drawer-body { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.drawer-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +/* ========== Tooltip弹窗样式 ========== */ +.tooltip-modal-content { + padding: 8px; +} + +.tooltip-modal-item { + padding: 12px 16px; + border-radius: var(--radius-sm); + transition: var(--transition); +} + +.tooltip-modal-item:hover { + background: var(--bg-base); +} + +.tooltip-modal-item-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.tooltip-modal-item-desc { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + padding-left: 28px; +} + +/* ========== 帮助弹窗内容 ========== */ +.help-modal-content { + max-height: 400px; + overflow-y: auto; +} + +.help-modal-item { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.help-modal-item:last-child { + border-bottom: none; +} + +.help-modal-item-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.help-modal-item-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--primary-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); +} + +.help-modal-item-desc { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + padding-left: 42px; +} + +/* ========== 解析失败提示 ========== */ +.parse-error-tip { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 16px; + background: #fff2f0; + border: 1px solid #ffccc7; + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--danger); + margin-top: 12px; +} + +/* ========== 无权限状态 ========== */ +.no-permission { + text-align: center; + padding: 60px 20px; +} + +.no-permission-icon { + width: 80px; + height: 80px; + margin: 0 auto 24px; + background: #fff2f0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--danger); +} + +.no-permission-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.no-permission-desc { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 24px; +} + +/* ========== 网络异常状态 ========== */ +.network-error { + text-align: center; + padding: 60px 20px; +} + +.network-error-icon { + width: 80px; + height: 80px; + margin: 0 auto 24px; + background: #fffbe6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--warning); +} + +.network-error-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.network-error-desc { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 24px; +} + +/* ========== Diff对比 ========== */ +.diff-viewer { + font-family: 'SF Mono', Monaco, monospace; + font-size: 13px; + line-height: 1.6; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.diff-row { + display: flex; + padding: 4px 12px; +} + +.diff-row.added { + background: #f6ffed; + color: var(--success); +} + +.diff-row.removed { + background: #fff2f0; + color: var(--danger); +} + +.diff-row-number { + width: 40px; + color: var(--text-muted); + user-select: none; +} + +.diff-row-content { + flex: 1; +} + +/* ========== 响应式调整 ========== */ +@media (max-width: 768px) { + .kb-type-grid { + grid-template-columns: 1fr; + } + + .file-compare { + grid-template-columns: 1fr; + } + + .kb-detail-header { + flex-direction: column; + } + + .kb-detail-actions { + width: 100%; + justify-content: flex-start; + } + + .wizard-steps { + padding: 0; + } + + .wizard-step-label { + display: none; + } +} + +/* ========== 打印样式 ========== */ +@media print { + .sidebar, + .topbar, + .no-print { + display: none !important; + } + + .main-content { + width: 100% !important; + margin: 0 !important; + } + + .page-container { + padding: 0 !important; + } +} + + +/* ========== 应用管理页面样式 ========== */ +.app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.app-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + transition: var(--transition); + box-shadow: var(--shadow-sm); +} + +.app-card:hover { + box-shadow: var(--shadow); + border-color: var(--primary-light); +} + +.app-card-new { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + border-style: dashed; + cursor: pointer; + color: var(--text-muted); +} + +.app-card-new:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--primary-bg); +} + +.app-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.app-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 20px; + font-size: 11.5px; + font-weight: 500; +} + +.app-card-body { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +.app-icon { + width: 48px; + height: 48px; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.app-info { + flex: 1; + min-width: 0; +} + +.app-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.app-desc { + font-size: 12.5px; + color: var(--text-secondary); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.app-meta { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; + padding: 10px; + background: var(--bg-base); + border-radius: var(--radius-sm); +} + +.app-meta-item { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12.5px; +} + +.app-meta-label { + color: var(--text-muted); +} + +.app-meta-val { + color: var(--text-primary); + font-weight: 500; +} + +.app-card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + position: relative; +} + +.app-action-config { + flex: 1; + min-height: 40px; + justify-content: center; + font-size: 13.5px; + font-weight: 600; +} + +.app-action-more { + display: flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; +} + +.app-action-more-trigger { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + border-radius: 10px; +} + +.app-action-more-trigger i, +.app-action-more-trigger svg { + width: 18px; + height: 18px; +} + +.app-action-menu { + position: absolute; + right: 0; + top: calc(100% + 8px); + z-index: 80; + min-width: 156px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-white); + box-shadow: 0 14px 34px rgba(19,45,26,0.12); + opacity: 0; + visibility: hidden; + transform: translateY(-4px); + transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease; +} + +.app-action-more.open .app-action-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.app-action-menu::after { + content: ''; + position: absolute; + right: 14px; + top: -5px; + width: 10px; + height: 10px; + background: var(--bg-white); + border-left: 1px solid var(--border); + border-top: 1px solid var(--border); + transform: rotate(45deg); +} + +.app-action-menu-item { + width: 100%; + display: flex; + align-items: center; + gap: 9px; + padding: 9px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + line-height: 1; + text-align: left; + cursor: pointer; + transition: var(--transition); +} + +.app-action-menu-item:hover { + background: var(--bg-base); + color: var(--text-primary); +} + +.app-action-menu-item.danger { + color: var(--danger); +} + +.app-action-menu-item.danger:hover { + background: #fff1f0; +} + +.app-action-menu-item i, +.app-action-menu-item svg { + width: 15px; + height: 15px; + flex-shrink: 0; +} + +.app-recycle-count { + min-width: 18px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: var(--primary-bg); + color: var(--primary); + font-size: 11px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.app-recycle-note { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px; + border: 1px solid #b7eb8f; + border-radius: 10px; + background: #f6ffed; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; +} + +.app-recycle-note i, +.app-recycle-note svg { + width: 16px; + height: 16px; + color: var(--primary); + flex-shrink: 0; + margin-top: 2px; +} + +.app-recycle-note strong, +.app-recycle-note span { + display: block; +} + +.app-recycle-note strong { + color: var(--text-primary); + margin-bottom: 2px; +} + +.app-recycle-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.app-recycle-summary { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-base); +} + +.app-recycle-summary strong { + display: block; + font-size: 24px; + line-height: 1; + color: var(--primary); + margin-bottom: 5px; +} + +.app-recycle-summary span, +.app-recycle-summary p { + font-size: 12.5px; + color: var(--text-muted); +} + +.app-recycle-summary p { + max-width: 520px; + margin: 0; + line-height: 1.6; +} + +.app-recycle-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 52vh; + overflow-y: auto; + padding-right: 2px; +} + +.app-recycle-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-card); + transition: var(--transition); +} + +.app-recycle-item:hover { + border-color: var(--primary-light); + box-shadow: var(--shadow-sm); +} + +.app-recycle-item-main { + display: flex; + gap: 12px; + align-items: flex-start; + min-width: 0; +} + +.app-recycle-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.app-recycle-icon i, +.app-recycle-icon svg { + width: 19px; + height: 19px; +} + +.app-recycle-name { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.app-recycle-meta, +.app-recycle-time { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.app-recycle-time { + margin-top: 4px; +} + +.app-recycle-actions { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; +} + +.btn-ghost.danger { + color: var(--danger); +} + +.btn-ghost.danger:hover { + background: #fff1f0; + border-color: #ffccc7; +} + +.app-recycle-empty { + min-height: 220px; +} + +/* 应用类型选择器 */ +.app-type-selector { + display: block; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); +} + +.app-type-selector:hover { + border-color: var(--primary); +} + +.app-type-selector.selected { + border-color: var(--primary); + background: var(--primary-bg); + box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.2); +} + +/* ========================================== + 应用配置页面样式 (agent-config) + ========================================== */ + +/* 整体布局 */ +.agent-config-layout { + display: grid; + grid-template-columns: 640px minmax(0, 1fr); + gap: 24px; + height: calc(100vh - var(--topbar-height)); + min-height: 0; + padding: 24px; + background: + linear-gradient(180deg, rgba(246,255,237,0.72) 0%, rgba(247,250,247,0) 220px), + #f7faf7; +} + +.agent-config-left { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + width: 640px; +} + +.agent-config-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 0 4px 0 0; + scrollbar-gutter: stable; +} + +.agent-config-right { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +/* 应用标题栏 */ +.agent-config-title-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; + flex-shrink: 0; + box-shadow: 0 8px 22px rgba(82,196,26,0.05); +} + +.agent-title-info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.agent-title-main { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-title-text { + font-size: 19px; + font-weight: 700; + color: var(--text-primary); + outline: none; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + transition: var(--transition); + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + +.agent-title-text:hover { + border-color: transparent; + background: transparent; +} + +.agent-title-text:focus { + border-color: var(--primary); + background: var(--bg-white); +} + +.agent-title-meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.agent-title-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; +} + +.agent-id-text { + font-size: 12px; + color: var(--text-muted); + font-family: 'SF Mono', Monaco, monospace; +} + +.autosave-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-muted); + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-base); + white-space: nowrap; +} + +.autosave-status.saved { + color: var(--success); + background: #f6ffed; +} + +.autosave-status.saving, +.autosave-status.dirty { + color: var(--text-secondary); + background: #fafafa; +} + +.autosave-status.error { + color: var(--danger); + background: #fff2f0; +} + +.version-history-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.version-history-desc { + font-size: 13px; + line-height: 1.6; + color: var(--text-muted); + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); +} + +.version-timeline { + max-height: 62vh; + overflow-y: auto; + padding-right: 4px; +} + +.version-timeline-item { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; +} + +.version-timeline-line { + position: relative; + display: flex; + justify-content: center; +} + +.version-timeline-line::after { + content: ''; + position: absolute; + top: 30px; + bottom: -14px; + width: 1px; + background: var(--border); +} + +.version-timeline-item:last-child .version-timeline-line::after { + display: none; +} + +.version-timeline-dot { + position: relative; + z-index: 1; + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + background: var(--bg-white); + border: 1px solid var(--border); +} + +.version-timeline-item.current .version-timeline-dot { + color: var(--primary); + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.version-timeline-item.online .version-timeline-dot { + color: var(--success); + border-color: #b7eb8f; + background: #f6ffed; +} + +.version-card { + margin-bottom: 14px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-white); +} + +.version-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.version-card-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.version-card-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); +} + +.version-meta-grid, +.version-publish-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.version-meta-grid div, +.version-publish-summary div { + padding: 10px; + border-radius: 8px; + background: var(--bg-base); + min-width: 0; +} + +.version-meta-grid span, +.version-publish-summary span, +.version-info span { + display: block; + margin-bottom: 4px; + font-size: 12px; + color: var(--text-muted); +} + +.version-meta-grid strong, +.version-publish-summary strong { + display: block; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-info { + margin-top: 12px; +} + +.version-info p { + margin: 0; + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); +} + +.publish-version-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.version-publish-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.hotword-library-summary { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); +} + +.hotword-summary-main { + min-width: 0; +} + +.hotword-summary-title { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.hotword-summary-desc { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +.hotword-manager, +.hotword-editor { + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal.hotword-editor-modal { + height: min(760px, 90vh); + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.hotword-editor-modal .modal-header { + flex-shrink: 0; + margin-bottom: 0; + padding: 24px 28px 18px; + border-bottom: 1px solid var(--border); +} + +.hotword-editor-modal .modal-body { + flex: 1; + min-height: 0; + padding: 18px 28px; + overflow: hidden; +} + +.hotword-editor-modal .modal-footer { + flex-shrink: 0; + margin-top: 0; + padding: 16px 28px 20px; + background: var(--bg-white); + border-top: 1px solid var(--border); +} + +.hotword-editor-modal .hotword-editor { + height: 100%; + min-height: 0; +} + +.hotword-manager-head, +.hotword-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.hotword-manager-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.hotword-manager-desc { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +.hotword-group-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 52vh; + overflow-y: auto; +} + +.hotword-group-item { + display: grid; + grid-template-columns: minmax(0, 1fr) 72px 150px 168px; + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-white); +} + +.hotword-group-name { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.hotword-group-preview, +.hotword-group-time, +.hotword-group-count { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +.hotword-group-count { + color: var(--text-secondary); + font-weight: 600; +} + +.hotword-group-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.hotword-entry-list { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + max-height: 360px; + overflow-y: auto; + background: var(--bg-white); +} + +.hotword-editor-modal .hotword-entry-list { + flex: 1; + min-height: 160px; + max-height: none; + overflow-y: auto; +} + +.hotword-entry-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px 96px; + gap: 10px; + align-items: center; + padding: 10px; + border-bottom: 1px solid var(--border); +} + +.hotword-entry-row:last-child { + border-bottom: 0; +} + +.hotword-empty-row { + padding: 24px 16px; + text-align: center; + font-size: 13px; + color: var(--text-muted); +} + +.hotword-select-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.hotword-select-card { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-white); + cursor: pointer; + transition: var(--transition); +} + +.hotword-select-card input { + display: none; +} + +.hotword-select-card:hover { + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.hotword-select-card.selected { + border-color: var(--primary); + background: var(--primary-bg); + box-shadow: 0 0 0 2px rgba(82,196,26,0.08); +} + +.hotword-select-check { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid var(--border-dark); + color: transparent; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; +} + +.hotword-select-card.selected .hotword-select-check { + border-color: var(--primary); + background: var(--primary); + color: #fff; +} + +.hotword-select-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.hotword-select-name { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} + +.hotword-select-preview, +.hotword-select-time { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +/* 配置区块 */ +.config-section { + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + padding: 18px 20px; + margin-bottom: 16px; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(18,45,18,0.03); +} + +.config-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.config-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.config-section-tip { + font-size: 12px; + color: var(--text-muted); +} + +.selected-scene-card { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fbfdfb; + cursor: pointer; + text-align: left; + transition: var(--transition); +} + +.selected-scene-card:hover { + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.selected-scene-icon, +.scene-modal-icon { + width: 38px; + height: 38px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + background: var(--primary-bg); + color: var(--primary); + flex-shrink: 0; +} + +.selected-scene-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.selected-scene-name { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.selected-scene-version { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +.selected-scene-action { + color: var(--text-muted); + display: flex; + align-items: center; +} + +.scene-modal-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.scene-modal-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.scene-modal-title { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} + +.scene-modal-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.scene-modal-card { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: var(--transition); + background: var(--bg-white); +} + +.scene-modal-card input, +.scene-version-card input { + display: none; +} + +.scene-modal-card:hover, +.scene-version-card:hover { + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.scene-modal-card.selected, +.scene-version-card.selected { + border-color: var(--primary); + background: var(--primary-bg); + box-shadow: 0 0 0 2px rgba(82,196,26,0.08); +} + +.scene-modal-card.selected .scene-modal-icon { + background: var(--primary); + color: #fff; +} + +.scene-modal-card[data-tooltip]::after { + width: 220px; + white-space: normal; + text-align: left; + line-height: 1.5; + padding: 8px 10px; +} + +.scene-modal-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.scene-version-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.scene-version-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: var(--transition); + background: var(--bg-white); +} + +.scene-version-name { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.scene-version-desc { + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); +} + +/* 场景模板网格 */ +.scene-template-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.scene-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + text-align: center; +} + +.scene-item:hover { + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.scene-item.selected { + border-color: var(--primary); + background: var(--primary-bg); +} + +.scene-item span:first-child { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--bg-base); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: var(--transition); +} + +.scene-item.selected span:first-child { + background: var(--primary); + color: #fff; +} + +.scene-item span:last-child { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.3; +} + +.scene-item.selected span:last-child { + color: var(--primary-dark); + font-weight: 500; +} + +/* 模块标签 */ +.module-tabs { + display: flex; + gap: 6px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px; + margin-bottom: 16px; + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 20; + box-shadow: 0 8px 20px rgba(82,196,26,0.06); +} + +.module-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); + text-align: center; +} + +.module-tab:hover { + color: var(--primary); + background: #f7fbf6; +} + +.module-tab.active { + background: var(--primary); + color: #fff; + box-shadow: 0 4px 10px rgba(82,196,26,0.16); +} + +.module-tab.active .help-tip { + background: rgba(255,255,255,0.2); + color: #fff; +} + +.module-tab .help-tip { + width: 16px; + height: 16px; + font-size: 10px; + background: var(--bg-base); +} + +/* 模块内容区 */ +.module-content { + flex: none; + overflow: visible; + background: transparent; + border: 0; + border-radius: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 18px; +} + +.config-module-section { + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + scroll-margin-top: 76px; + box-shadow: 0 1px 2px rgba(18,45,18,0.03); +} + +.config-module-heading { + padding: 18px 20px; + border-bottom: 1px solid var(--border); + background: #fbfdfb; +} + +.config-module-heading-main { + display: flex; + align-items: center; + gap: 10px; +} + +.config-module-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--primary-bg); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.config-module-title { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.4; +} + +.config-module-desc { + margin-top: 2px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.module-panel { + padding: 18px 20px; +} + +/* 配置行 */ +.config-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 0; + border-bottom: 1px solid var(--border); + gap: 16px; +} + +.config-row:last-child { + border-bottom: none; +} + +.config-row.indent { + padding-left: 18px; +} + +.prompt-config-row { + display: block; +} + +.prompt-editor-field { + width: 100%; + min-width: 0; +} + +.config-row-label { + font-size: 13px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + line-height: 1.5; +} + +/* 配置子区块 */ +.config-subsection { + margin-bottom: 20px; +} + +.config-subsection-title { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 6px; +} + +.config-subsection-subtitle { + font-size: 12px; + color: var(--text-secondary); + margin: 12px 0 8px; + padding-left: 16px; +} + +/* 表单控件尺寸 */ +.form-control-sm { + padding: 6px 10px; + font-size: 13px; +} + +select.form-control-sm { + max-width: 200px; +} + +/* 芯片组 */ +.chip-group { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); + user-select: none; +} + +.chip input[type="checkbox"] { + display: none; +} + +.chip:hover { + border-color: var(--primary-light); +} + +.chip.selected { + border-color: var(--primary); + background: var(--primary-bg); + color: var(--primary); +} + +/* 单选框组 */ +.radio-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.radio-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition); +} + +.radio-item:has(input:checked) { + border-color: var(--primary); + background: var(--primary-bg); + color: var(--primary); +} + +.radio-item input[type="radio"] { + accent-color: var(--primary); +} + +/* 提示词编辑器 */ +.prompt-editor-wrap { + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.prompt-editor { + width: 100%; + height: 400px; + min-height: 400px; + resize: vertical; + border: none; + outline: none; + padding: 12px; + font-size: 13px; + line-height: 1.7; + font-family: inherit; + background: var(--bg-white); +} + +.prompt-toolbar { + display: flex; + gap: 4px; + padding: 6px 12px; + background: #fafafa; + border-top: 1px solid var(--border); +} + +.btn-xs { + padding: 3px 8px; + font-size: 11px; +} + +.prompt-char-count { + position: absolute; + bottom: 8px; + right: 12px; + font-size: 11px; + color: var(--text-muted); + background: rgba(255,255,255,0.9); + padding: 2px 6px; + border-radius: 4px; +} + +/* 知识库列表 */ +.kb-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kb-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-base); + border-radius: var(--radius); + flex: 1; +} + +/* 底部操作栏 */ +.agent-config-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-top: 16px; + flex-shrink: 0; +} + +/* 技能网格 */ +.skill-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.skill-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + position: relative; + opacity: 0.5; +} + +.skill-item.enabled { + opacity: 1; + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.skill-item:hover { + opacity: 1; + border-color: var(--primary); +} + +.skill-item.add-skill { + border-style: dashed; + opacity: 0.7; +} + +.skill-item.add-skill:hover { + opacity: 1; + border-color: var(--primary); + background: var(--primary-bg); +} + +.skill-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--bg-base); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.skill-item.enabled .skill-icon { + background: var(--primary); + color: #fff; +} + +.skill-name { + font-size: 11px; + color: var(--text-secondary); + text-align: center; +} + +.skill-item.enabled .skill-name { + color: var(--primary-dark); + font-weight: 500; +} + +.skill-del { + position: absolute; + top: 4px; + right: 4px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--danger); + color: #fff; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: var(--transition); +} + +.skill-item.enabled:hover .skill-del { + opacity: 1; +} + +/* ========================================== + 对话测试面板样式 + ========================================== */ + +.chat-test-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 8px 22px rgba(82,196,26,0.05); +} + +.chat-test-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: #fbfdfb; +} + +.preview-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.preview-title-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); + background: var(--primary-bg); + flex-shrink: 0; +} + +.preview-title-main { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.4; +} + +.preview-title-sub { + margin-top: 2px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; + background: linear-gradient(180deg, #ffffff 0%, #fbfdfb 100%); +} + +.chat-empty-hint { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.chat-message { + display: flex; + align-items: flex-start; + gap: 10px; + animation: fadeIn 0.2s ease; +} + +.chat-message.user { + flex-direction: row-reverse; +} + +.chat-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.user-avatar { + background: var(--bg-base); + color: var(--text-secondary); +} + +.bot-avatar { + background: var(--primary); + color: #fff; +} + +.chat-bubble { + max-width: 75%; + padding: 10px 14px; + border-radius: 12px; + font-size: 13.5px; + line-height: 1.5; +} + +.user-bubble { + background: var(--primary); + color: #fff; + border-bottom-right-radius: 4px; +} + +.bot-bubble { + background: var(--bg-base); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +/* 打字指示器 */ +.typing-dots { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.typing-dots span { + width: 6px; + height: 6px; + background: var(--text-muted); + border-radius: 50%; + animation: typingBounce 1.4s infinite ease-in-out; +} + +.typing-dots span:nth-child(1) { animation-delay: 0s; } +.typing-dots span:nth-child(2) { animation-delay: 0.2s; } +.typing-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typingBounce { + 0%, 80%, 100% { transform: scale(1); opacity: 0.4; } + 40% { transform: scale(1.2); opacity: 1; } +} + +/* 输入区域 */ +.chat-input-area { + padding: 16px 18px; + border-top: 1px solid var(--border); + background: #fff; + flex-shrink: 0; +} + +.chat-textarea { + width: 100%; + resize: none; + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + font-size: 13px; + outline: none; + transition: var(--transition); + background: var(--bg-white); + line-height: 1.5; +} + +.chat-textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(82,196,26,0.08); +} + +.chat-textarea:disabled { + background: var(--bg-base); + cursor: not-allowed; +} + +.chat-voice-btn { + width: 40px; + height: 40px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* ========================================== + Help Tooltip 样式 + ========================================== */ + +.help-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-base); + color: var(--text-muted); + font-size: 10px; + cursor: pointer; + transition: var(--transition); + flex-shrink: 0; +} + +.help-tip:hover { + background: var(--primary); + color: #fff; +} + +.help-tooltip { + position: fixed; + background: #1a1a1a; + color: #fff; + font-size: 12px; + padding: 8px 12px; + border-radius: var(--radius-sm); + max-width: 280px; + z-index: 10000; + box-shadow: var(--shadow-lg); + line-height: 1.5; + display: none; + pointer-events: none; + white-space: pre-wrap; +} + +/* ========================================== + 旋转动画 + ========================================== */ + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spin { + animation: spin 1s linear infinite; +} + +/* ========================================== + 响应式调整 + ========================================== */ + +@media (max-width: 1200px) { + .page-container.agent-config-page-container { + overflow-y: auto; + } + + .agent-config-layout { + grid-template-columns: 1fr; + height: auto; + min-height: calc(100vh - var(--topbar-height)); + padding: 16px; + } + + .agent-config-left, + .agent-config-scroll, + .module-content { + overflow: visible; + } + + .agent-config-left { + width: auto; + } + + .agent-config-right { + height: 520px; + } +} + +@media (max-width: 768px) { + .scene-template-grid { + grid-template-columns: repeat(2, 1fr); + } + + .scene-modal-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .scene-version-grid { + grid-template-columns: 1fr; + } + + .module-tabs { + flex-wrap: wrap; + } + + .module-tab { + flex: 1 1 45%; + } + + .skill-grid { + grid-template-columns: repeat(3, 1fr); + } + + .agent-config-title-bar, + .version-card-head { + flex-direction: column; + align-items: stretch; + } + + .agent-title-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .version-meta-grid, + .version-publish-summary { + grid-template-columns: 1fr; + } + + .bailian-split-grid, + .bailian-filter-summary, + .bailian-edge-grid, + .bailian-check-grid, + .bailian-variable-grid, + .bailian-mode-grid, + .bailian-model-mode-grid, + .bailian-mini-grid, + .bailian-budget-grid { + grid-template-columns: 1fr; + } + + .bailian-command-row { + grid-template-columns: 1fr; + } + + .bailian-filter-row { + grid-template-columns: 1fr; + } + + .bailian-filter-row.single { + grid-template-columns: 1fr; + } + + .bailian-section-inline-action, + .bailian-filter-preview, + .bailian-feature-head, + .bailian-filter-head { + flex-direction: column; + align-items: stretch; + } + + .hotword-library-summary, + .hotword-manager-head, + .hotword-editor-head { + flex-direction: column; + align-items: stretch; + } + + .hotword-group-item { + grid-template-columns: 1fr; + } + + .hotword-group-actions { + justify-content: flex-start; + } + + .hotword-entry-row { + grid-template-columns: 1fr; + } + + .modal.hotword-editor-modal { + height: 92vh; + } + + .hotword-editor-modal .modal-header, + .hotword-editor-modal .modal-body, + .hotword-editor-modal .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + .hotword-select-list { + grid-template-columns: 1fr; + } + + .config-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +/* ========================================== + 统计卡片样式 (stat-*) + ========================================== */ + +.scope-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid rgba(82,196,26,0.16); + border-radius: 14px; + background: + linear-gradient(90deg, rgba(82,196,26,0.06) 0%, rgba(82,196,26,0.015) 44%, rgba(255,255,255,0.92) 100%), + var(--bg-white); + box-shadow: 0 2px 8px rgba(0,0,0,0.025); +} + +.scope-banner-main { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} + +.scope-banner-icon { + width: 42px; + height: 42px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--primary); + background: rgba(82,196,26,0.1); + border: 1px solid rgba(82,196,26,0.16); +} + +.scope-banner-icon i, +.scope-banner-icon svg { + width: 20px; + height: 20px; +} + +.scope-banner-eyebrow { + margin-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: var(--primary); +} + +.scope-banner-title { + font-size: 16px; + line-height: 1.2; + font-weight: 700; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.scope-banner-desc { + margin-top: 5px; + font-size: 12.5px; + line-height: 1.5; + color: var(--text-muted); +} + +.scope-banner-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.scope-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255,255,255,0.78); + color: var(--text-muted); + font-size: 12px; + line-height: 1; +} + +.scope-chip strong { + color: var(--text-primary); + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.scope-chip.warning strong { + color: var(--warning); +} + +.scope-chip.danger strong { + color: var(--danger); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(164px, 1fr)); + gap: 14px; +} + +.stats-grid-device { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.stats-grid-ota { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.stat-card { + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px; + display: flex; + align-items: center; + gap: 12px; + transition: var(--transition); + min-width: 0; + min-height: 82px; +} + +.stat-card:hover { + box-shadow: var(--shadow); + border-color: var(--primary-light); + transform: translateY(-1px); +} + +.stat-icon { + width: 42px; + height: 42px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-primary); +} + +.stat-icon i, +.stat-icon svg { + width: 20px; + height: 20px; +} + +@media (max-width: 1180px) { + .stats-grid-ota { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 960px) { + .scope-banner { + align-items: flex-start; + flex-direction: column; + } + + .scope-banner-meta { + justify-content: flex-start; + width: 100%; + } + + .stats-grid-device, + .stats-grid-ota { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .scope-banner { + padding: 14px; + } + + .scope-banner-main { + align-items: flex-start; + } + + .scope-banner-title { + white-space: normal; + } + + .scope-chip { + flex: 1; + justify-content: space-between; + min-width: 132px; + } + + .stats-grid-device, + .stats-grid-ota { + grid-template-columns: 1fr; + } +} + +.stat-content { + flex: 1; + min-width: 0; +} + +.stat-value { + font-size: 26px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + margin-bottom: 0; + letter-spacing: 0; + font-variant-numeric: tabular-nums; +} + +.stat-label { + margin-top: 7px; + font-size: 12px; + line-height: 1.2; + color: var(--text-muted); + white-space: nowrap; +} + +/* ========================================== + Tooltip Box 样式 + ========================================== */ + +.tooltip-box { + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 14px; + box-shadow: var(--shadow); + font-size: 12.5px; + color: var(--text-primary); + max-width: 240px; + z-index: 1000; +} + +/* ========================================== + 设备状态相关 + ========================================== */ + +.device-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; +} + +.device-status::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.device-status.online::before { background: var(--success); } +.device-status.offline::before { background: var(--text-muted); } +.device-status.warning::before { background: var(--warning); } +.device-status.error::before { background: var(--danger); } + +/* ========================================== + 固件版本标签 + ========================================== */ + +.firmware-version { + font-family: 'SF Mono', Monaco, monospace; + font-size: 12px; + padding: 2px 8px; + background: var(--bg-base); + border-radius: var(--radius-sm); + color: var(--text-secondary); +} + +/* ========================================== + 进度环形 + ========================================== */ + +.progress-ring { + width: 60px; + height: 60px; + position: relative; +} + +.progress-ring svg { + transform: rotate(-90deg); +} + +.progress-ring-bg { + fill: none; + stroke: var(--border); + stroke-width: 6; +} + +.progress-ring-fill { + fill: none; + stroke: var(--primary); + stroke-width: 6; + stroke-linecap: round; + transition: stroke-dashoffset 0.6s ease; +} + +.progress-ring-text { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +/* ========================================== + 设备列表操作按钮 + ========================================== */ + +.device-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.device-action-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: var(--transition); + cursor: pointer; +} + +.device-action-btn:hover { + background: var(--bg-base); + color: var(--primary); +} + +.device-action-btn.danger:hover { + background: #fff2f0; + color: var(--danger); +} + +/* ========================================== + 登录页面样式 + ========================================== */ + +.login-center-wrapper { + width: 100%; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%); + padding: 40px 20px; +} + +.login-center-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 420px; +} + +/* 品牌区域 */ +.login-brand { + text-align: center; + margin-bottom: 32px; +} + +.brand-title { + font-size: 32px; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.5px; + margin-bottom: 8px; + display: inline-block; +} + +.brand-title a { + display: inline-block; + position: relative; + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.brand-title a:hover, +.brand-title a:visited { + color: inherit; +} + +.brand-title a::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + transform: translateX(-50%) translateY(4px) scale(0.92); + padding: 6px 10px; + border-radius: 8px; + background: rgba(17, 24, 39, 0.92); + color: #fff; + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + line-height: 1; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14); +} + +.brand-title a::before { + content: ''; + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%) translateY(4px); + border: 6px solid transparent; + border-top-color: rgba(17, 24, 39, 0.92); + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; +} + +.brand-title a:hover::after, +.brand-title a:hover::before, +.brand-title a.tooltip-visible::after, +.brand-title a.tooltip-visible::before { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); +} + +.brand-title a.tooltip-suppressed::after, +.brand-title a.tooltip-suppressed::before { + opacity: 0; + transform: translateX(-50%) translateY(4px) scale(0.92); +} + +.brand-slogan { + font-size: 15px; + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 2px; +} + +.brand-slogan-en { + font-size: 13px; + color: var(--text-secondary); + font-weight: 400; +} + +/* 登录卡片 */ +.login-form-card { + width: 100%; + background: #fff; + border-radius: var(--radius-lg); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04); + padding: 40px 36px; +} + +.login-form-header { + text-align: center; + margin-bottom: 28px; +} + +.login-title { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.login-subtitle { + font-size: 14px; + color: var(--text-secondary); +} + +/* 角色选择器 */ +.role-selector { + display: flex; + gap: 10px; + margin-bottom: 24px; +} + +.role-option { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 8px; + border: 1.5px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition); + background: #fff; +} + +.role-option:hover { + border-color: var(--primary-light); + background: var(--primary-bg); +} + +.role-option.selected { + border-color: var(--primary); + background: var(--primary-bg); + box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.1); +} + +.role-icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-base); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: var(--transition); +} + +.role-option.selected .role-icon { + background: var(--primary); + color: #fff; +} + +.role-info { + text-align: center; +} + +.role-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.role-desc { + font-size: 11px; + color: var(--text-tertiary); +} + +/* 登录表单 */ +.login-form .form-group { + margin-bottom: 20px; +} + +.login-form .form-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 8px; +} + +.login-form .input-wrapper { + position: relative; +} + +.login-form .input-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + width: 18px; + height: 18px; + pointer-events: none; +} + +.login-form .form-control.with-icon { + padding-left: 42px; + padding-right: 44px; +} + +.password-toggle { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.password-toggle:hover { + color: var(--text-secondary); +} + +/* 记住登录状态 */ +.login-form .form-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--primary); + cursor: pointer; +} + +/* 登录按钮 */ +.login-form .btn-block { + width: 100%; + justify-content: center; +} + +.login-form .btn-lg { + padding: 12px 24px; + font-size: 15px; + font-weight: 600; +} + +/* 登录页脚 */ +.login-footer { + text-align: center; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--border-color); +} + +.login-footer p { + font-size: 13px; + color: var(--text-secondary); +} + +.login-footer .link { + color: var(--primary); + font-weight: 500; +} + +.login-footer .link:hover { + text-decoration: underline; +} + +/* 登录页响应式 */ +@media (max-width: 480px) { + .login-form-card { + padding: 28px 20px; + } + + .role-selector { + gap: 6px; + } + + .role-option { + padding: 10px 4px; + } + + .role-icon { + width: 32px; + height: 32px; + } + + .role-name { + font-size: 12px; + } + + .role-desc { + font-size: 10px; + } +} + +/* ========================================== + 侧边栏公司导航样式 + ========================================== */ + +.company-nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin: 2px 8px; + border-radius: var(--radius-md); + cursor: pointer; + color: rgba(255,255,255,0.65); + font-size: 13px; + transition: var(--transition); + position: relative; + flex-wrap: wrap; + border-left: 3px solid transparent; + padding-left: 9px; +} + +.company-nav-item:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.company-nav-item.active { + color: #fff; + background: linear-gradient(90deg, rgba(82,196,26,0.25) 0%, rgba(82,196,26,0.08) 100%); + border-left-color: var(--primary); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); +} + +.company-nav-item i { + width: 14px; + height: 14px; + flex-shrink: 0; + opacity: 0.8; +} + +.company-nav-item.active i { + opacity: 1; +} + +.company-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.company-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; + font-weight: 600; + color: #fff; + flex-shrink: 0; +} + +.company-nav-item.active .company-badge { + background: var(--primary); + color: #fff; +} + +.sidebar.collapsed .company-nav-item { + flex-wrap: nowrap; + gap: 0; + color: rgba(255,255,255,0.58); + border: 1px solid transparent; +} + +.sidebar.collapsed .company-nav-item:hover { + color: #fff; + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.08); + box-shadow: none; + transform: none; +} + +.sidebar.collapsed .company-nav-item.active { + color: #fff; + background: rgba(82,196,26,0.18); + border-color: rgba(82,196,26,0.24); + box-shadow: inset 0 0 0 1px rgba(82,196,26,0.08); +} + +.sidebar.collapsed .company-nav-item.active::before { + content: ''; + position: absolute; + left: -9px; + top: 10px; + width: 3px; + height: 20px; + border-radius: 999px; + background: var(--primary); + box-shadow: 0 0 12px rgba(82,196,26,0.65); +} + +.sidebar.collapsed .company-nav-item::after { + content: none; + display: none; +} + +.sidebar.collapsed .company-nav-item i, +.sidebar.collapsed .company-nav-item svg { + display: block; + opacity: 1; +} + +.sidebar.collapsed .company-name { + position: absolute; + left: 60px; + top: 50%; + z-index: 120; + width: max-content; + max-width: 220px; + padding: 8px 11px; + border-radius: 11px; + background: rgba(18,26,21,0.96); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; + font-size: 12px; + font-weight: 600; + opacity: 0; + pointer-events: none; + transform: translate(-6px, -50%); + box-shadow: 0 14px 34px rgba(19,45,26,0.16); +} + +.sidebar.collapsed .company-name::before { + content: ''; + position: absolute; + left: -5px; + top: 50%; + width: 10px; + height: 10px; + background: rgba(18,26,21,0.96); + border-left: 1px solid rgba(255,255,255,0.1); + border-bottom: 1px solid rgba(255,255,255,0.1); + transform: translateY(-50%) rotate(45deg); +} + +.sidebar.collapsed .company-nav-item:hover .company-name { + opacity: 1; + transform: translate(0, -50%); +} + +.sidebar.collapsed .company-badge { + position: absolute; + right: 8px; + top: 8px; + min-width: 8px; + width: 8px; + height: 8px; + padding: 0; + font-size: 0; + background: #52c41a; + border: 2px solid #111713; + box-shadow: 0 0 0 2px rgba(82,196,26,0.12); +} + +.sidebar.collapsed .company-submenu { + display: none !important; +} + +/* 公司子菜单 - 默认收起,点击展开 */ +.company-submenu { + width: 100%; + display: none; + flex-direction: column; + gap: 2px; + margin-top: 6px; + padding-left: 22px; +} + +.company-submenu.open { + display: flex; +} + +.submenu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-sm); + color: rgba(255, 255, 255, 0.7); + font-size: 12px; + text-decoration: none; + transition: var(--transition); +} + +.submenu-item:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.submenu-item.active { + color: #fff; + background: linear-gradient(90deg, rgba(82,196,26,0.22) 0%, rgba(82,196,26,0.07) 100%); + box-shadow: inset 2px 0 0 var(--primary); +} + +.submenu-item i { + width: 13px; + height: 13px; + flex-shrink: 0; + opacity: 0.7; +} + +.submenu-item:hover i, +.submenu-item.active i { + opacity: 1; + color: var(--primary); +} + +/* ========================================== + Bailian-style Agent Configuration Workbench + ========================================== */ + +body.agent-workbench-mode { + display: block; + overflow: hidden; + background: #f7f8fc; +} + +body.agent-workbench-mode .sidebar, +body.agent-workbench-mode .topbar { + display: none !important; +} + +body.agent-workbench-mode .main-content { + width: 100%; + height: 100vh; + margin-left: 0; +} + +body.agent-workbench-mode .page-container { + height: 100vh; + padding: 0; + overflow: hidden; + background: #f7f8fc; +} + +.page-container.agent-config-page-container { + padding: 0; + overflow: hidden; + background: #f7f8fc; +} + +.page-container.agent-config-page-container .page.active { + height: 100%; +} + +.bailian-agent-config { + --agent-green: #52c41a; + --agent-green-dark: #389e0d; + --agent-ink: #111827; + --agent-text: #263142; + --agent-muted: #7b8494; + --agent-border: #e5e7f0; + --agent-soft: #f7f8fc; + --agent-card-shadow: 0 2px 8px rgba(0,0,0,0.03); + height: 100vh; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--agent-soft); + color: var(--agent-text); + font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif; +} + +.bailian-agent-header { + position: sticky; + top: 0; + z-index: 50; + height: 64px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 24px; + background: #fff; + border-bottom: 1px solid var(--agent-border); +} + +.bailian-header-left, +.bailian-header-actions, +.bailian-agent-title-row { + display: flex; + align-items: center; + min-width: 0; +} + +.bailian-header-left { + gap: 14px; +} + +.bailian-agent-title-wrap { + min-width: 0; +} + +.bailian-agent-title-row { + gap: 8px; +} + +.bailian-agent-title { + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + font-size: 18px; + line-height: 1.2; + font-weight: 650; + color: var(--agent-ink); + letter-spacing: 0; +} + +.bailian-title-edit-btn { + width: 24px; + height: 24px; + border-radius: 7px; +} + +.bailian-title-edit-btn i, +.bailian-title-edit-btn svg { + width: 14px; + height: 14px; +} + +.bailian-agent-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--agent-muted); +} + +.bailian-header-actions { + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; +} + +.bailian-icon-btn, +.bailian-plain-icon, +.bailian-action-btn, +.bailian-link-btn, +.bailian-run-btn, +.bailian-add-round, +.bailian-add-card, +.bailian-scene-card, +.bailian-model-select, +.bailian-step-tab { + font-family: inherit; +} + +.bailian-icon-btn, +.bailian-plain-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: #606a7b; + transition: border-color .18s ease, background .18s ease, color .18s ease, transform .18s ease; +} + +.bailian-icon-btn { + width: 40px; + height: 40px; + border: 1px solid var(--agent-border); + border-radius: 10px; + background: #fff; +} + +.bailian-icon-btn:hover, +.bailian-plain-icon:hover { + color: var(--agent-ink); + border-color: #cfd5e3; + background: #fafbfe; + transform: translateY(-1px); +} + +.bailian-icon-btn i, +.bailian-action-btn i, +.bailian-plain-icon i, +.bailian-link-btn i, +.bailian-run-btn i, +.bailian-add-round i, +.bailian-add-card i { + width: 16px; + height: 16px; +} + +.bailian-plain-icon { + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; +} + +.bailian-plain-icon.danger:hover { + color: var(--danger); + background: #fff2f0; + border-color: #ffccc7; +} + +.bailian-action-btn { + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 16px; + border: 1px solid var(--agent-border); + border-radius: 10px; + background: #fff; + color: var(--agent-text); + font-size: 14px; + font-weight: 500; + white-space: nowrap; + transition: border-color .18s ease, background .18s ease, color .18s ease, transform .18s ease, box-shadow .18s ease; +} + +.bailian-action-btn:hover { + border-color: #cfd5e3; + background: #fbfcff; + transform: translateY(-1px); + box-shadow: var(--agent-card-shadow); +} + +.bailian-action-btn.small { + height: 34px; + padding: 0 12px; + font-size: 13px; +} + +.bailian-action-icon { + width: 40px; + padding: 0; +} + +.bailian-primary-btn, +.bailian-run-btn { + border-color: var(--agent-ink); + background: var(--agent-ink); + color: #fff; +} + +.bailian-primary-btn:hover, +.bailian-run-btn:hover { + border-color: #243044; + background: #243044; + color: #fff; +} + +.bailian-status-pill, +.bailian-small-pill { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.bailian-status-pill.published { + color: var(--agent-green-dark); + background: #f6ffed; + border: 1px solid #b7eb8f; +} + +.bailian-status-pill.editing { + color: #b26a00; + background: #fff7e6; + border: 1px solid #ffd591; +} + +.bailian-status-pill.draft { + color: #606a7b; + background: #f3f5f9; + border: 1px solid var(--agent-border); +} + +.bailian-agent-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 640px minmax(0, 1fr); + background: var(--agent-soft); +} + +.bailian-config-pane { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: #fff; + border-right: 1px solid var(--agent-border); +} + +.bailian-step-tabs { + height: 72px; + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; + border-bottom: 1px solid var(--agent-border); + background: #fff; +} + +.bailian-step-tab { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + color: #8a93a3; + background: #fff; + transition: color .18s ease, background .18s ease; +} + +.bailian-step-tab::after { + content: ""; + position: absolute; + left: 18px; + right: 18px; + bottom: -1px; + height: 2px; + border-radius: 2px; + background: transparent; + transform: scaleX(.5); + opacity: 0; + transition: transform .2s ease, opacity .2s ease, background .2s ease; +} + +.bailian-step-tab:hover { + color: var(--agent-ink); + background: #fbfcff; +} + +.bailian-step-tab.active { + color: var(--agent-green-dark); +} + +.bailian-step-tab.active::after { + background: var(--agent-green); + transform: scaleX(1); + opacity: 1; +} + +.bailian-step-number { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid var(--agent-border); + background: #fff; + color: #8a93a3; + font-size: 12px; + font-weight: 700; + transition: color .18s ease, border-color .18s ease, background .18s ease; +} + +.bailian-step-tab.active .bailian-step-number { + color: #fff; + border-color: var(--agent-green); + background: var(--agent-green); +} + +.bailian-step-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} + +.bailian-config-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 24px; + scrollbar-width: thin; + scrollbar-color: #dce1eb transparent; +} + +.bailian-config-scroll::-webkit-scrollbar, +.bailian-preview-pane::-webkit-scrollbar { + width: 4px; +} + +.bailian-config-scroll::-webkit-scrollbar-thumb, +.bailian-preview-pane::-webkit-scrollbar-thumb { + background: #dce1eb; + border-radius: 999px; +} + +.bailian-step-content { + display: flex; + flex-direction: column; + gap: 18px; + padding-bottom: 28px; +} + +.bailian-step-section { + scroll-margin-top: 88px; +} + +.bailian-step-section + .bailian-step-section { + margin-top: 30px; + padding-top: 26px; + border-top: 1px solid #edf0f6; +} + +.bailian-step-section-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.bailian-step-section-head span { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #111827; + color: #fff; + font-size: 13px; + font-weight: 700; +} + +.bailian-step-section-head h2 { + margin: 0; + font-size: 20px; + line-height: 1.3; + font-weight: 700; + color: var(--agent-ink); +} + +.bailian-card-section, +.bailian-scene-card { + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; + box-shadow: var(--agent-card-shadow); +} + +.bailian-card-section { + padding: 18px; +} + +.bailian-card-section:hover, +.bailian-scene-card:hover, +.bailian-model-select:hover, +.bailian-list-card:hover, +.bailian-kb-card:hover, +.bailian-mini-card:hover, +.bailian-mode-card:hover { + border-color: #cfd5e3; + background: #fdfefe; +} + +.bailian-card-section h3, +.bailian-section-head h3 { + margin: 0 0 14px; + font-size: 18px; + font-weight: 700; + line-height: 1.35; + color: var(--agent-ink); +} + +.bailian-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.bailian-section-head h3 { + margin-bottom: 0; +} + +.bailian-stack, +.bailian-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.bailian-scene-card { + min-height: 72px; + width: 100%; + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + text-align: left; + color: var(--agent-text); + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} + +.bailian-scene-card:hover { + transform: translateY(-1px); +} + +.bailian-scene-icon, +.bailian-model-icon, +.bailian-list-icon, +.bailian-mini-card span { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--agent-green-dark); + background: #f6ffed; + border: 1px solid #e0f7d2; +} + +.bailian-scene-icon { + width: 42px; + height: 42px; + border-radius: 12px; +} + +.bailian-scene-icon i, +.bailian-model-icon i, +.bailian-list-icon i, +.bailian-mini-card i { + width: 18px; + height: 18px; +} + +.bailian-scene-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} + +.bailian-scene-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.bailian-scene-title-row strong { + font-size: 15px; + color: var(--agent-ink); +} + +.bailian-scene-title-row em { + font-style: normal; + padding: 2px 9px; + border-radius: 999px; + color: var(--agent-green-dark); + background: #f6ffed; + font-size: 12px; + font-weight: 600; +} + +.bailian-scene-main span:last-child { + font-size: 12px; + color: var(--agent-muted); +} + +.bailian-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bailian-field-label { + font-size: 12px; + color: var(--agent-muted); +} + +.bailian-input, +.bailian-number-input { + height: 48px; + border: 1px solid var(--agent-border); + border-radius: 10px; + background: #fff; + color: var(--agent-text); + outline: none; + font-size: 14px; + font-family: inherit; + transition: border-color .18s ease, box-shadow .18s ease; +} + +.bailian-input { + width: 100%; + padding: 0 14px; +} + +.bailian-number-input { + width: 92px; + padding: 0 12px; + text-align: center; +} + +.bailian-input:focus, +.bailian-number-input:focus, +.bailian-prompt-editor:focus { + border-color: var(--agent-ink); + box-shadow: 0 0 0 3px rgba(17,24,39,0.06); +} + +.bailian-model-select { + min-height: 64px; + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; + text-align: left; + color: var(--agent-text); + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} + +.bailian-model-select:hover { + transform: translateY(-1px); +} + +.bailian-model-icon, +.bailian-list-icon { + width: 38px; + height: 38px; + border-radius: 10px; +} + +.bailian-model-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.bailian-model-main strong { + font-size: 14px; + color: var(--agent-ink); +} + +.bailian-model-main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--agent-muted); +} + +.bailian-hotword-box, +.bailian-switch-row, +.bailian-section-inline-action, +.bailian-mcp-empty, +.bailian-inline-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.bailian-hotword-box { + padding: 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fbfcff; +} + +.bailian-split-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.bailian-feature-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; +} + +.bailian-feature-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.bailian-feature-title { + font-size: 14px; + font-weight: 700; + color: var(--agent-ink); +} + +.bailian-feature-desc { + margin-top: 4px; + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-model-desc, +.bailian-section-note { + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-section-note { + padding: 10px 12px; + border-radius: 10px; + background: #fafbfe; + border: 1px solid #edf0f6; +} + +.bailian-voice-disabled { + padding: 24px; + border: 1px dashed var(--agent-border); + border-radius: 12px; + background: #fff; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.bailian-voice-disabled strong { + font-size: 15px; + color: var(--agent-ink); +} + +.bailian-voice-disabled span { + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-voice-toggle-card { + padding: 14px 18px; +} + +.bailian-voice-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.bailian-voice-section { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 16px; + border-top: 1px solid #edf0f6; +} + +.bailian-voice-section:first-of-type { + padding-top: 0; + border-top: 0; +} + +.bailian-voice-subtitle { + font-size: 15px; + font-weight: 700; + color: var(--agent-ink); +} + +.bailian-hotword-title, +.bailian-switch-title { + font-size: 14px; + font-weight: 650; + color: var(--agent-ink); +} + +.bailian-hotword-desc, +.bailian-switch-desc, +.bailian-section-inline-action, +.bailian-list-desc, +.bailian-variable-desc, +.bailian-mini-card small, +.bailian-mcp-empty p { + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-link-btn { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + border-radius: 8px; + color: var(--agent-green-dark); + background: #f6ffed; + border: 1px solid #d9f7be; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: background .18s ease, transform .18s ease; +} + +.bailian-link-btn:hover { + background: #edffd9; + transform: translateY(-1px); +} + +.bailian-token-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.bailian-token { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid #d9f7be; + background: #f6ffed; + color: var(--agent-green-dark); + font-size: 12px; + font-weight: 600; +} + +.agent-switch { + position: relative; + width: 42px; + height: 24px; + flex-shrink: 0; +} + +.agent-switch input { + position: absolute; + opacity: 0; +} + +.agent-switch span { + position: absolute; + inset: 0; + border-radius: 999px; + background: #d5dae5; + cursor: pointer; + transition: background .2s ease; +} + +.agent-switch span::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,.15); + transition: transform .2s ease; +} + +.agent-switch input:checked + span { + background: var(--agent-ink); +} + +.agent-switch input:checked + span::after { + transform: translateX(18px); +} + +.bailian-radio-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bailian-radio-list.compact { + gap: 8px; +} + +.bailian-check-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} + +.bailian-check-card:hover { + border-color: #cfd5e3; + background: #fbfcff; + transform: translateY(-1px); +} + +.bailian-check-card input { + accent-color: var(--agent-green-dark); +} + +.bailian-check-card span { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.bailian-check-card strong { + font-size: 14px; + color: var(--agent-ink); +} + +.bailian-check-card small { + font-size: 12px; + line-height: 1.5; + color: var(--agent-muted); +} + +.bailian-check-card.readonly { + opacity: .82; +} + +.bailian-check-card.readonly .bailian-radio-dot { + margin-left: 2px; +} + +.bailian-edge-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.bailian-edge-card { + padding: 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; +} + +.bailian-edge-card strong { + display: block; + font-size: 14px; + color: var(--agent-ink); +} + +.bailian-edge-card span { + display: block; + margin-top: 6px; + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-collapse-head { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0; + border: 0; + background: transparent; + color: var(--agent-text); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.bailian-filter-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; +} + +.bailian-filter-note { + flex: 1; + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-filter-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.bailian-filter-group { + padding: 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; +} + +.bailian-filter-group-title { + font-size: 13px; + font-weight: 700; + color: var(--agent-ink); + margin-bottom: 10px; +} + +.bailian-radio-card { + min-height: 46px; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--agent-border); + border-radius: 12px; + cursor: pointer; + transition: border-color .18s ease, background .18s ease; +} + +.bailian-radio-card input { + display: none; +} + +.bailian-radio-card:hover, +.bailian-radio-card:has(input:checked) { + border-color: var(--agent-ink); + background: #fbfcff; +} + +.bailian-radio-dot { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid #c8cfdd; + box-shadow: inset 0 0 0 4px #fff; + background: #fff; + transition: border-color .18s ease, background .18s ease; +} + +.bailian-radio-card:has(input:checked) .bailian-radio-dot { + border-color: var(--agent-ink); + background: var(--agent-ink); +} + +.bailian-radio-card span:last-child { + font-size: 14px; + color: var(--agent-text); +} + +.bailian-run-btn { + width: 100%; + height: 48px; + border-radius: 12px; + font-size: 15px; + font-weight: 700; + transition: background .18s ease, transform .18s ease, box-shadow .18s ease; +} + +.bailian-run-btn:hover { + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(17,24,39,.12); +} + +.prompt-section { + padding-bottom: 0; + overflow: hidden; +} + +.bailian-prompt-editor { + width: 100%; + height: 360px; + min-height: 360px; + resize: vertical; + padding: 16px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; + color: var(--agent-text); + outline: none; + font-family: "SF Mono", Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; + line-height: 1.7; +} + +.bailian-prompt-footer { + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--agent-muted); + font-size: 12px; +} + +.bailian-prompt-footer strong { + color: var(--agent-text); + font-weight: 600; +} + +.bailian-variable-grid, +.bailian-mode-grid, +.bailian-model-mode-grid, +.bailian-mini-grid, +.bailian-budget-grid { + display: grid; + gap: 12px; +} + +.bailian-variable-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.bailian-mode-grid, +.bailian-budget-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.bailian-variable-card, +.bailian-add-card, +.bailian-mode-card, +.bailian-mini-card, +.bailian-list-card, +.bailian-kb-card, +.bailian-mcp-empty { + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} + +.bailian-variable-card, +.bailian-add-card, +.bailian-mode-card, +.bailian-mini-card { + padding: 14px; +} + +.bailian-variable-name, +.bailian-list-title, +.bailian-mini-card strong, +.bailian-mcp-empty strong { + font-size: 14px; + font-weight: 700; + color: var(--agent-ink); +} + +.bailian-add-card { + min-height: 72px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--agent-muted); + border-style: dashed; +} + +.bailian-add-card.compact { + min-height: 108px; + flex-direction: column; +} + +.bailian-add-card:hover { + color: var(--agent-green-dark); + border-color: var(--agent-green); + background: #f6ffed; + transform: translateY(-1px); +} + +.bailian-inline-field { + justify-content: flex-start; + color: var(--agent-muted); + font-size: 14px; +} + +.bailian-section-inline-action { + margin: -4px 0 12px; +} + +.bailian-list-card, +.bailian-kb-card { + min-height: 72px; + display: flex; + align-items: center; + gap: 12px; + padding: 14px; +} + +.bailian-list-main { + flex: 1; + min-width: 0; +} + +.bailian-list-title { + display: flex; + align-items: center; + gap: 8px; +} + +.bailian-list-title span, +.bailian-kb-weight { + color: var(--agent-green-dark); + font-size: 12px; + font-weight: 700; +} + +.bailian-list-desc { + margin-top: 4px; +} + +.bailian-small-pill { + color: var(--agent-green-dark); + background: #f6ffed; + border: 1px solid #d9f7be; +} + +.bailian-kb-weight { + margin-top: 6px; +} + +.bailian-mode-card { + cursor: pointer; +} + +.bailian-mode-card input { + display: none; +} + +.bailian-mode-card strong, +.bailian-mode-card span { + display: block; +} + +.bailian-mode-card strong { + margin-bottom: 6px; + font-size: 14px; + color: var(--agent-ink); +} + +.bailian-mode-card span { + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-mode-card.active, +.bailian-mode-card:has(input:checked) { + border-color: var(--agent-green); + background: #f6ffed; + box-shadow: 0 0 0 3px rgba(82,196,26,.08); +} + +.bailian-mini-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.bailian-mini-card { + min-height: 108px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.bailian-mini-card span { + width: 34px; + height: 34px; + border-radius: 10px; +} + +.bailian-mcp-empty { + padding: 16px; +} + +.bailian-mcp-empty p { + max-width: 420px; + margin: 6px 0 0; +} + +.bailian-empty-inline { + padding: 12px 0; + font-size: 12px; + color: var(--agent-muted); +} + +.bailian-weight-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 110px; + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid var(--agent-border); + border-radius: 12px; + background: #fff; +} + +.bailian-weight-name { + font-size: 14px; + font-weight: 700; + color: var(--agent-ink); +} + +.bailian-weight-desc { + margin-top: 4px; + font-size: 12px; + line-height: 1.6; + color: var(--agent-muted); +} + +.bailian-weight-input { + width: 100%; +} + +.bailian-filter-modal, +.bailian-command-modal { + display: flex; + flex-direction: column; + gap: 14px; +} + +.bailian-filter-modal-desc { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid #edf0f6; + background: #fafbfe; + font-size: 12px; + line-height: 1.7; + color: var(--agent-muted); +} + +.bailian-filter-modal-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bailian-filter-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.bailian-check-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.bailian-filter-row, +.bailian-command-row { + display: grid; + gap: 10px; + align-items: center; +} + +.bailian-filter-row { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px; + margin-top: 10px; +} + +.bailian-filter-row.single { + grid-template-columns: minmax(0, 1fr) 34px; +} + +.bailian-command-row { + grid-template-columns: 160px minmax(0, 1.4fr) minmax(0, 1fr) 120px 120px 34px; + padding: 10px 0; + border-bottom: 1px solid #edf0f6; +} + +.bailian-command-row:last-child { + border-bottom: 0; +} + +.bailian-command-table-wrap { + overflow: auto; + border: 1px solid var(--agent-border); + border-radius: 12px; +} + +.bailian-command-table { + width: 100%; + border-collapse: collapse; + background: #fff; +} + +.bailian-command-table th, +.bailian-command-table td { + padding: 12px; + border-bottom: 1px solid #edf0f6; + text-align: left; + vertical-align: top; + font-size: 12px; +} + +.bailian-command-table th { + position: sticky; + top: 0; + z-index: 1; + background: #fbfcff; + color: var(--agent-ink); + font-weight: 700; +} + +.bailian-command-table td .form-control, +.bailian-command-table td textarea, +.bailian-command-table td input { + width: 100%; + min-width: 0; +} + +.bailian-command-table textarea { + min-height: 70px; + padding: 10px 12px; + resize: vertical; +} + +.bailian-command-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; +} + +.bailian-add-round { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 50%; + background: var(--agent-ink); + color: #fff; + transition: background .18s ease, transform .18s ease; +} + +.bailian-add-round:hover { + background: #243044; + transform: translateY(-1px); +} + +.bailian-preview-pane { + min-width: 0; + min-height: 0; + overflow: auto; + background: #f7f8fc; + display: flex; + align-items: center; + justify-content: center; + padding: 48px; +} + +.bailian-preview-empty { + max-width: 420px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.bailian-preview-empty h2 { + margin: 28px 0 8px; + font-size: 22px; + line-height: 1.35; + font-weight: 700; + color: var(--agent-ink); +} + +.bailian-preview-empty p { + margin: 0; + font-size: 14px; + line-height: 1.7; + color: var(--agent-muted); +} + +.bailian-preview-tip { + margin-top: 18px; + display: inline-flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(255,255,255,.72); + border: 1px solid var(--agent-border); + color: var(--agent-muted); + font-size: 12px; + line-height: 1.6; +} + +.bailian-preview-tip i { + width: 15px; + height: 15px; + margin-top: 2px; + color: #a5adbb; +} + +.bailian-cube { + position: relative; + width: 112px; + height: 96px; + opacity: .85; +} + +.bailian-cube span { + position: absolute; + border: 1px solid #d6dbe6; + background: linear-gradient(145deg, #f8f9fc, #e8ebf2); + box-shadow: 0 18px 32px rgba(23,31,45,.07); +} + +.bailian-cube span:nth-child(1) { + left: 28px; + top: 18px; + width: 56px; + height: 56px; + border-radius: 12px; + transform: rotate(45deg) skew(-8deg, -8deg); +} + +.bailian-cube span:nth-child(2) { + left: 8px; + top: 44px; + width: 50px; + height: 38px; + border-radius: 10px; + transform: skewY(22deg); + background: linear-gradient(145deg, #eef1f7, #dde2ec); +} + +.bailian-cube span:nth-child(3) { + right: 8px; + top: 44px; + width: 50px; + height: 38px; + border-radius: 10px; + transform: skewY(-22deg); + background: linear-gradient(145deg, #f7f8fb, #e5e9f1); +} + +.bailian-scene-modal { + display: flex; + flex-direction: column; + gap: 22px; +} + +.bailian-scene-modal-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.bailian-scene-modal-title { + font-size: 14px; + font-weight: 700; + color: #111827; +} + +.bailian-scene-option-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.bailian-scene-option, +.bailian-scene-version-option { + border: 1px solid #e5e7f0; + border-radius: 12px; + background: #fff; + cursor: pointer; + transition: border-color .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease; +} + +.bailian-scene-option { + min-height: 112px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 14px; +} + +.bailian-scene-option input, +.bailian-scene-version-option input { + display: none; +} + +.bailian-scene-option:hover, +.bailian-scene-version-option:hover { + border-color: #cfd5e3; + background: #fbfcff; + transform: translateY(-1px); +} + +.bailian-scene-option.selected, +.bailian-scene-version-option.selected { + border-color: #52c41a; + background: #f6ffed; + box-shadow: 0 0 0 3px rgba(82,196,26,.08); +} + +.bailian-scene-option-icon { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + color: #389e0d; + background: #f6ffed; + border: 1px solid #d9f7be; +} + +.bailian-scene-option.selected .bailian-scene-option-icon { + color: #fff; + background: #52c41a; + border-color: #52c41a; +} + +.bailian-scene-option-icon i { + width: 18px; + height: 18px; +} + +.bailian-scene-option-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} + +.bailian-scene-option-main strong, +.bailian-scene-version-option strong { + font-size: 14px; + font-weight: 700; + color: #111827; +} + +.bailian-scene-option-main small, +.bailian-scene-version-option span { + font-size: 12px; + line-height: 1.55; + color: #7b8494; +} + +.bailian-scene-version-grid, +.bailian-scene-language-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.bailian-scene-version-option, +.bailian-scene-language-option { + min-height: 96px; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; +} + +.bailian-scene-language-option { + border: 1px solid #e5e7f0; + border-radius: 12px; + background: #fff; + cursor: pointer; + transition: border-color .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease; +} + +.bailian-scene-language-option input { + display: none; +} + +.bailian-scene-language-option:hover { + border-color: #cfd5e3; + background: #fbfcff; + transform: translateY(-1px); +} + +.bailian-scene-language-option.selected { + border-color: #52c41a; + background: #f6ffed; + box-shadow: 0 0 0 3px rgba(82,196,26,.08); +} + +.bailian-scene-language-option strong { + font-size: 14px; + font-weight: 700; + color: #111827; +} + +.bailian-scene-language-option span { + font-size: 12px; + line-height: 1.55; + color: #7b8494; +} + +.bailian-required { + color: #ff4d4f; + font-size: 13px; +} + +@media (max-width: 1180px) { + .bailian-agent-body { + grid-template-columns: minmax(420px, 54vw) minmax(0, 1fr); + } + + .bailian-header-actions { + gap: 8px; + } + + .bailian-action-btn { + padding: 0 12px; + } +} + +@media (max-width: 960px) { + body.agent-workbench-mode .page-container { + overflow-y: auto; + } + + .bailian-agent-config { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .bailian-agent-header { + height: auto; + min-height: 64px; + flex-wrap: wrap; + padding: 12px 16px; + } + + .bailian-header-actions { + width: 100%; + justify-content: flex-start; + overflow-x: auto; + padding-bottom: 2px; + } + + .bailian-agent-body { + grid-template-columns: 1fr; + } + + .bailian-config-pane { + border-right: 0; + border-bottom: 1px solid var(--agent-border); + } + + .bailian-config-scroll { + overflow: visible; + } + + .bailian-preview-pane { + min-height: 420px; + } +} + +@media (max-width: 640px) { + .app-recycle-summary, + .app-recycle-item, + .app-recycle-actions { + flex-direction: column; + align-items: stretch; + } + + .app-recycle-actions .btn { + width: 100%; + justify-content: center; + } + + .bailian-agent-title { + max-width: 220px; + font-size: 17px; + } + + .bailian-step-tabs { + height: auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .bailian-step-tab { + min-height: 58px; + } + + .bailian-config-scroll { + padding: 16px; + } + + .bailian-card-section { + padding: 16px; + } + + .bailian-variable-grid, + .bailian-mode-grid, + .bailian-mini-grid, + .bailian-budget-grid { + grid-template-columns: 1fr; + } + + .bailian-hotword-box, + .bailian-switch-row, + .bailian-section-inline-action, + .bailian-mcp-empty, + .bailian-list-card, + .bailian-kb-card { + align-items: flex-start; + } + + .bailian-hotword-box, + .bailian-mcp-empty, + .bailian-section-inline-action { + flex-direction: column; + } + + .bailian-list-card, + .bailian-kb-card { + flex-wrap: wrap; + } + + .bailian-prompt-editor { + height: 300px; + min-height: 300px; + } + + .bailian-scene-option-grid, + .bailian-scene-version-grid, + .bailian-scene-language-grid { + grid-template-columns: 1fr; + } + + .bailian-preview-pane { + padding: 32px 18px; + } +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..33cc076 --- /dev/null +++ b/test.html @@ -0,0 +1,97 @@ + + + + + + TalkingQ - Test + + + +
+
+ + + + + + + + + + +