From 83964afcfc6f704865afa400a9099de7356d954d Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Thu, 20 Aug 2026 15:58:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor(client):=20=E5=A4=96=E5=9B=B4=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=8E=9F=E7=94=9F=E5=8C=96=E5=B9=B6=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=EF=BC=8C=E7=BC=A9=E5=B0=8F=20SDK=20?= =?UTF-8?q?=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t-select/t-dialog/t-alert/t-tag/t-button 替换为原生 DOM,删 5 条组件 import 与 omi 直接依赖,min 版缩小约 212KB - 清理死代码:images.d.ts、失效测试、dist 残留、getMessages、死类型、showAdminPanel、未用 i18n key、t-tag 死 CSS - 修复 i18n 缺失 confirm/cancel 翻译与自绘确认框 ESC 冲突 --- client/README.md | 45 +++---- client/src/chat.ts | 25 ++-- client/src/config.ts | 17 ++- client/src/dom.ts | 256 ++++++++++++++++++------------------ client/src/i18n.ts | 32 +---- client/src/images.d.ts | 5 - client/src/index.ts | 13 +- client/src/logger.ts | 10 -- client/src/styles.ts | 261 ++++++++++++++++++++++--------------- client/src/types.ts | 35 +---- client/tests/i18n.test.ts | 100 -------------- client/tests/utils.test.ts | 86 ------------ 12 files changed, 346 insertions(+), 539 deletions(-) delete mode 100644 client/src/images.d.ts delete mode 100644 client/tests/i18n.test.ts delete mode 100644 client/tests/utils.test.ts diff --git a/client/README.md b/client/README.md index 49ab818..f772624 100644 --- a/client/README.md +++ b/client/README.md @@ -72,28 +72,29 @@ SDK 产物位于 `client/dist/` 目录: ## 二、SDKConfig 完整参数 -| 参数 | 类型 | 必传 | 默认值 | 阶段 | 说明 | -|------|------|------|--------|------|------| -| `integrateId` | `string \| number` | ✅ | — | P0 | 集成标识 → 后端 `roleId`(客服角色 ID,决定 AI 人设和知识库范围) | -| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 | -| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` | -| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID | -| `enableRag` | `boolean` | ❌ | `false` | P1 | 启用 RAG 知识库检索对话(走 `/ai/chat/stream` 接口,`enableRag=true`) | -| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 | -| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 | -| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 | -| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) | +| 参数 | 类型 | 必传 | 默认值 | 阶段 | 说明 | +|------|------|------|------------------|------|------| +| `integrateId` | `string \| number` | ✅ | — | P0 | 集成标识 → 后端 `roleId`(客服角色 ID,决定 AI 人设和知识库范围) | +| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 | +| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` | +| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID | +| `enableRag` | `boolean` | ❌ | `false` | P1 | 启用 RAG 知识库检索对话(走 `/ai/chat/stream` 接口,`enableRag=true`) | +| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 | +| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 | +| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 | +| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) | | `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 | -| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 | -| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 | -| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 | -| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 | -| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` | -| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) | -| `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 | -| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 | -| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` | -| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 | +| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 | +| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 | +| `launcherSize` | `number \| string` | ❌ | `80` | P0 | 悬浮按钮尺寸:数字=px(范围 36~96),或 CSS 长度字符串如 `"4rem"`/`"5vw"`/`"clamp(48px,3vw,80px)"` 用于响应式适配 | +| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 | +| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 | +| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` | +| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) | +| `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 | +| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 | +| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` | +| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 | --- @@ -410,7 +411,7 @@ client/ | **P0 核心链路** | ✅ 已完成 | 项目骨架、配置校验、样式注入、悬浮按钮+弹窗、HTTP 通信、对话核心、本地缓存、打包构建 | | **P1 体验增强** | ✅ 已完成 | SSE 流式打字机、Markdown 渲染、知识库下拉切换、RAG 引用来源展示、UI 全配置化、弹窗拖拽 | | **P2 运营完善** | ✅ 已完成 | 多语言国际化(zh-CN/en)、控制台日志体系、会话管理面板(列表/导出/删除) | -| **可选扩展** | 🔜 待定 | 知识库管理嵌入(`showAdminPanel`)、更多语言、主题皮肤 | +| **可选扩展** | 🔜 待定 | 知识库管理嵌入、更多语言、主题皮肤 | --- diff --git a/client/src/chat.ts b/client/src/chat.ts index c848bfc..d6882d2 100644 --- a/client/src/chat.ts +++ b/client/src/chat.ts @@ -39,7 +39,6 @@ import { showOfflineBanner, hideOfflineBanner, renderSuggestions, - removeSuggestions, showFeedbackReasonPanel, resetChatActionFeedback, confirmWithDialog, @@ -655,26 +654,29 @@ async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promi } } -/** 加载知识库分类到下拉框(t-select,选项含缩进前缀表达层级) */ +/** 加载知识库分类到原生下拉框(选项含缩进前缀表达层级) */ async function loadCategories(): Promise { if (!categorySelect) return; try { const tree = await fetchCategoryTree(); if (tree.length === 0) return; - const options: Array<{ label: string; value: string }> = [{ label: t('category_all'), value: '' }]; + const select = categorySelect as HTMLSelectElement; + // 清空后重建(首项为"全部分类"占位) + select.innerHTML = ''; + select.appendChild(new Option(t('category_all'), '')); + const addOptions = (nodes: typeof tree, indent: number = 0) => { for (const node of nodes) { - options.push({ label: `${' '.repeat(indent)}${node.name}`, value: String(node.id) }); + select.appendChild(new Option(`${' '.repeat(indent)}${node.name}`, String(node.id))); if (node.children && node.children.length > 0) addOptions(node.children, indent + 1); } }; addOptions(tree); - (categorySelect as unknown as { options: Array<{ label: string; value: string }> }).options = options; // 若已有选中分类,回填选中值 if (currentCategoryId !== undefined) { - (categorySelect as unknown as { value: string }).value = String(currentCategoryId); + select.value = String(currentCategoryId); } logger.info(`知识库分类加载成功 count=${tree.length}`); } catch (err) { @@ -799,7 +801,7 @@ export async function switchRole(newRoleId: string): Promise { // 7. 更新角色下拉框选中项 if (roleSelect) { - (roleSelect as unknown as { value: string }).value = newRoleId; + (roleSelect as HTMLSelectElement).value = newRoleId; } // 8. 强制查询后端会话列表(跳过 localStorage 缓存) @@ -889,7 +891,7 @@ export async function loadHistoryConversations(): Promise { * 如果会话所属角色与当前角色不同,自动同步切换角色 * @param conversationId 会话 ID(即 chatId) */ -export async function switchToConversation(conversationId: string): Promise { +async function switchToConversation(conversationId: string): Promise { if (!config || !messagesContainer) return; // 找到该会话对应的历史条目,获取其所属角色 @@ -906,7 +908,7 @@ export async function switchToConversation(conversationId: string): Promise`; const resolvedPrimaryColor = raw.primaryColor || '#4F46E5'; + // 悬浮按钮尺寸:number → px 并 clamp 到 [36, 96];string → 合法 CSS 长度透传,否则回退默认 60 + const launcherSize: number | string = typeof raw.launcherSize === 'number' + ? Math.min(96, Math.max(36, raw.launcherSize)) + : (typeof raw.launcherSize === 'string' && isCssLength(raw.launcherSize.trim()) + ? raw.launcherSize.trim() + : 80); // 填充默认值 const config: ResolvedConfig = { @@ -56,8 +71,8 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null { position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom', primaryColor: resolvedPrimaryColor, launcherIcon: resolvedLauncherIcon, + launcherSize, showClear: raw.showClear ?? true, - showAdminPanel: raw.showAdminPanel ?? false, quickReplies: Array.isArray(raw.quickReplies) ? raw.quickReplies.map(s => String(s).trim()).filter(Boolean) : [], diff --git a/client/src/dom.ts b/client/src/dom.ts index 5132366..5a4f710 100644 --- a/client/src/dom.ts +++ b/client/src/dom.ts @@ -10,39 +10,12 @@ import { ResolvedConfig, RagSource } from './types'; import { debounce, formatTime, formatHistoryTime } from './utils'; import { t } from './i18n'; -import { DialogPlugin } from 'tdesign-web-components/lib/dialog/index.js'; -// omi 的 render/h 用于创建带 on* 函数回调的组件(见 createEventful 说明) -import { render, h } from 'omi'; - -/** - * 用 omi render 创建带函数事件回调的 TDesign 组件。 - * - * 为什么不能直接 createElement + property 赋值: - * omi(tdesign-web-components 底层框架)的 isComplexType 对 /^on|children/ 前缀返回 false, - * 纯 DOM 下 on* 回调(onChange/onClick 等)既不走 handleComplexProps 的 accessor,也无法从 - * attribute 读取,导致 `el.onChange = fn` 静默失效;而 select/tag 内部是直接 - * `this.props.onChange.call()`,没有 fire 的 dispatchEvent 兜底,必须借助 omi render 把 - * 函数作为 vnode attributes 同步进 props。 - */ -function createEventful( - tagName: string, - props: Record, - container: HTMLElement, - children?: string | unknown[], -): HTMLElement { - // children 作为 vnode 的 children 传入(tag/button 等通过 children/slot 渲染文本,而非 content) - const vnode = children !== undefined ? h(tagName, props, children) : h(tagName, props); - return render(vnode, container) as HTMLElement; -} // ==================== 图标常量 ==================== /** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */ const BOT_ICON = ``; -/** 用户头像图标 */ -const USER_ICON = ``; - // ==================== 悬浮按钮 ==================== /** 创建悬浮按钮 */ @@ -55,6 +28,12 @@ export function createLauncher(config: ResolvedConfig, onClick: () => void): HTM launcher.setAttribute('role', 'button'); launcher.setAttribute('tabindex', '0'); + // 悬浮按钮尺寸通过 CSS 变量透传(.csk-launcher 挂在 body、在 .csk-root 之外,无法继承变量,需元素级设置) + launcher.style.setProperty( + '--csk-launcher-size', + typeof config.launcherSize === 'number' ? `${config.launcherSize}px` : config.launcherSize, + ); + // 图标内容 launcher.innerHTML = config.launcherIcon; @@ -186,7 +165,7 @@ export function createChatWindow(config: ResolvedConfig): { headerLeft.appendChild(headerAvatar); headerLeft.appendChild(headerInfo); - // === 角色选择器(仅当有多个角色时显示,t-select) === + // === 角色选择器(仅当有多个角色时显示,原生 select) === let roleSelect: HTMLElement | null = null; const roles = config.roles; if (roles && roles.length > 1) { @@ -194,21 +173,22 @@ export function createChatWindow(config: ResolvedConfig): { const roleWrap = document.createElement('div'); roleWrap.className = 'csk-role-select-wrap'; - // on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props - roleSelect = createEventful('t-select', { - options: roles.map(role => ({ label: role.name || String(role.id), value: String(role.id) })), - value: String(config.integrateId), - // onChange 触发自定义事件(受控:同步回写选中值) - onChange: (value: string | number) => { - if (roleSelect) { - (roleSelect as unknown as { value: string | number }).value = String(value); - } - windowEl.dispatchEvent(new CustomEvent('csk:roleChange', { - detail: { roleId: String(value) } - })); - }, - }, roleWrap); + roleSelect = document.createElement('select'); + roleSelect.className = 'csk-select'; + for (const role of roles) { + const opt = document.createElement('option'); + opt.value = String(role.id); + opt.textContent = role.name || String(role.id); + roleSelect.appendChild(opt); + } + (roleSelect as HTMLSelectElement).value = String(config.integrateId); roleSelect.setAttribute('aria-label', t('role_selector_label')); + roleSelect.addEventListener('change', () => { + windowEl.dispatchEvent(new CustomEvent('csk:roleChange', { + detail: { roleId: String((roleSelect as HTMLSelectElement).value) } + })); + }); + roleWrap.appendChild(roleSelect); header.appendChild(headerLeft); header.appendChild(roleWrap); @@ -262,19 +242,19 @@ export function createChatWindow(config: ResolvedConfig): {
${t('welcome_desc')}
`; - // 快捷问题芯片(点击即自动发送,由 t-tag 承载) + // 快捷问题芯片(点击即自动发送,原生 button) if (config.quickReplies.length > 0) { const chips = document.createElement('div'); chips.className = 'csk-quick-replies'; for (const reply of config.quickReplies) { - // on* 回调(onClick)必须经 omi render 创建;文本经 children 传入(tag 的 children 优先于 content) - createEventful('t-tag', { - theme: 'primary', - variant: 'light-outline', - onClick: () => { - windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } })); - }, - }, chips, reply); + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'csk-quick-reply'; + chip.textContent = reply; + chip.addEventListener('click', () => { + windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } })); + }); + chips.appendChild(chip); } welcomeEl.appendChild(chips); } @@ -329,7 +309,7 @@ export function createChatWindow(config: ResolvedConfig): { } }); - // === 知识库分类下拉框(t-select,选项由 loadCategories 异步填充) === + // === 知识库分类下拉框(原生 select,选项由 loadCategories 异步填充) === let categorySelect: HTMLElement | null = null; if (config.showCategorySwitch) { const categoryBar = document.createElement('div'); @@ -340,22 +320,18 @@ export function createChatWindow(config: ResolvedConfig): { categoryLabel.textContent = '📚'; categoryBar.appendChild(categoryLabel); - // on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props - categorySelect = createEventful('t-select', { - options: [{ label: t('category_all'), value: '' }], - value: '', - // onChange 触发自定义事件(受控:同步回写选中值) - onChange: (value: string | number) => { - if (categorySelect) { - (categorySelect as unknown as { value: string | number }).value = String(value); - } - const selectedId = String(value); - windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', { - detail: { categoryId: selectedId ? Number(selectedId) : undefined } - })); - }, - }, categoryBar); + categorySelect = document.createElement('select'); + categorySelect.className = 'csk-select'; categorySelect.id = 'csk-category-select'; + // 初始"全部分类"占位(后续 loadCategories 会重建完整选项) + categorySelect.appendChild(new Option(t('category_all'), '')); + categorySelect.addEventListener('change', () => { + const selectedId = (categorySelect as HTMLSelectElement).value; + windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', { + detail: { categoryId: selectedId ? Number(selectedId) : undefined } + })); + }); + categoryBar.appendChild(categorySelect); // 插入到 messages 和 inputArea 之间 windowEl.appendChild(header); @@ -554,9 +530,9 @@ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragE /** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */ const LAUNCHER_MIN_BOTTOM = 16; -/** Launcher 拖拽:计算当前视口下最大 bottom 值 */ -function launcherMaxBottom(): number { - return window.innerHeight - 76; // 按钮高度 60 + 16 间距 +/** Launcher 拖拽:计算当前视口下最大 bottom 值(按按钮实际高度动态计算,适配任意尺寸单位) */ +function launcherMaxBottom(launcherEl: HTMLElement): number { + return window.innerHeight - (launcherEl.getBoundingClientRect().height || 60) - LAUNCHER_MIN_BOTTOM; } /** @@ -628,7 +604,7 @@ export function enableLauncherDrag( if (hasMoved) { e.preventDefault(); // 阻止触摸滚动 - const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom())); + const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom(launcherEl))); launcherEl.style.bottom = `${newBottom}px`; } } @@ -641,7 +617,7 @@ export function enableLauncherDrag( // 隐藏拖拽态(保留当前 bottom) const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom(); - const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom())); + const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom(launcherEl))); // 判断吸附到哪一侧(取离哪边更近) const rect = launcherEl.getBoundingClientRect(); @@ -966,11 +942,8 @@ export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): voi // ==================== P1: RAG 引用来源渲染 ==================== /** - * 渲染 RAG 引用来源卡片(t-button 折叠开关 + 自定义来源列表) - * 说明:尝试用 替换,但 tdesign-web-components 的 collapse/collapse-panel - * 依赖 omi 的 provide/inject 机制,在纯 DOM(createElement + appendChild)下无法建立 - * 注入链路(报 `this.injection.getCollapseValue is not a function`),故折叠开关改用 - * t-button(标准组件、纯 DOM 可用),展开后的来源条目保留自定义渲染。 + * 渲染 RAG 引用来源卡片(原生 button 折叠开关 + 自定义来源列表) + * 展开后的来源条目保留自定义渲染。 */ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void { // 移除已有的来源卡片 @@ -982,12 +955,10 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void const sourcesEl = document.createElement('div'); sourcesEl.className = 'csk-sources csk-sources--collapsed'; - // 折叠开关:t-button(标准组件,紧凑宽度,默认折叠) - const toggle = document.createElement('t-button'); + // 折叠开关:原生 button(紧凑宽度,默认折叠) + const toggle = document.createElement('button'); + toggle.type = 'button'; toggle.className = 'csk-sources__toggle'; - toggle.setAttribute('theme', 'default'); - toggle.setAttribute('variant', 'text'); - toggle.setAttribute('size', 'small'); const updateToggleLabel = () => { const collapsed = sourcesEl.classList.contains('csk-sources--collapsed'); @@ -1217,14 +1188,13 @@ export function scrollToBottom(container: HTMLElement): void { // ==================== 离线提示横幅 ==================== -/** 在消息区顶部展示离线提示横幅(t-alert,theme=warning) */ +/** 在消息区顶部展示离线提示横幅(原生 div,warning 样式) */ export function showOfflineBanner(messagesContainer: HTMLElement): void { if (document.getElementById('csk-offline-banner')) return; - const banner = document.createElement('t-alert'); + const banner = document.createElement('div'); banner.id = 'csk-offline-banner'; - // message 为复杂类型(Object/Function 等),用 property;theme 为 String 简单类型,用 setAttribute - (banner as unknown as { message: string }).message = t('error_offline'); - banner.setAttribute('theme', 'warning'); + banner.className = 'csk-offline-banner'; + banner.textContent = t('error_offline'); messagesContainer.insertBefore(banner, messagesContainer.firstChild); } @@ -1259,12 +1229,10 @@ export function renderSuggestions( list.className = 'csk-suggestions__list'; for (const text of suggestions) { - // t-button 走 slot 文本 + addEventListener(内部 fire 派发 CustomEvent),无需 omi render - const btn = document.createElement('t-button'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'csk-suggestions__item'; btn.textContent = text; - btn.setAttribute('theme', 'default'); - btn.setAttribute('variant', 'text'); - btn.setAttribute('size', 'small'); btn.addEventListener('click', (e) => { e.stopPropagation(); onClick(text); @@ -1280,7 +1248,7 @@ export function renderSuggestions( /** * 移除指定 wrapper 中的建议问题列表 */ -export function removeSuggestions(wrapper: HTMLElement): void { +function removeSuggestions(wrapper: HTMLElement): void { const existing = wrapper.querySelector('.csk-suggestions'); if (existing) existing.remove(); } @@ -1296,44 +1264,82 @@ const REASON_OPTIONS: { key: string; labelKey: string }[] = [ ]; /** - * 命令式确认弹窗(DialogPlugin.confirm),替代原生 confirm() + * 命令式确认弹窗(自绘模态,替代原生 confirm()) * @returns 确认返回 true,取消/关闭返回 false */ export function confirmWithDialog(message: string): Promise { return new Promise((resolve) => { let settled = false; - const finish = (val: boolean): void => { + + const overlay = document.createElement('div'); + overlay.className = 'csk-confirm-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'csk-confirm'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + + const header = document.createElement('div'); + header.className = 'csk-confirm__header'; + header.textContent = t('confirm'); + dialog.appendChild(header); + + const body = document.createElement('div'); + body.className = 'csk-confirm__body'; + body.textContent = message; + dialog.appendChild(body); + + const footer = document.createElement('div'); + footer.className = 'csk-confirm__footer'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'csk-btn csk-btn--default'; + cancelBtn.textContent = t('cancel'); + + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = 'csk-btn csk-btn--primary'; + confirmBtn.textContent = t('confirm'); + + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + dialog.appendChild(footer); + overlay.appendChild(dialog); + + function cleanup(): void { + document.removeEventListener('keydown', onKeydown, true); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + function finish(val: boolean): void { if (settled) return; settled = true; + cleanup(); resolve(val); - }; - const dlg = DialogPlugin.confirm({ - header: t('confirm'), - body: message, - confirmBtn: t('confirm'), - cancelBtn: t('cancel'), - onConfirm: () => { - finish(true); - dlg.destroy(); - }, - onCancel: () => { - finish(false); - dlg.destroy(); - }, - onClose: () => { - // ESC / 遮罩点击 / 关闭按钮兜底 + } + + function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.stopPropagation(); finish(false); - dlg.destroy(); - }, + } + } + + cancelBtn.addEventListener('click', () => finish(false)); + confirmBtn.addEventListener('click', () => finish(true)); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finish(false); }); + + // 捕获阶段监听 ESC,阻止冒泡到 index.ts 的窗口 ESC 关闭逻辑,避免同时关闭聊天窗口 + document.addEventListener('keydown', onKeydown, true); + document.body.appendChild(overlay); + confirmBtn.focus(); }); } /** - * 在消息气泡下方展示点踩原因选择面板(内联,按钮用 t-button) - * 说明:尝试用 改为模态弹窗,但 dialog 的 visible/confirmBtn/cancelBtn 属性 - * 未在 omi propTypes 声明,纯 DOM property 赋值不生效(渲染为 display:none),且复杂 - * body 无法通过 DialogPlugin 注入,故保留内联面板形态,仅将按钮升级为 t-button。 + * 在消息气泡下方展示点踩原因选择面板(内联,按钮用原生 button) */ export function showFeedbackReasonPanel( wrapper: HTMLElement, @@ -1354,16 +1360,14 @@ export function showFeedbackReasonPanel( title.textContent = t('feedback_reason_title'); panel.appendChild(title); - // 原因选项按钮(t-button,点击即确认) + // 原因选项按钮(原生 button,点击即确认) const optionsRow = document.createElement('div'); optionsRow.className = 'csk-feedback-reason__options'; REASON_OPTIONS.forEach((opt) => { - const btn = document.createElement('t-button'); - // t-button 文本通过 渲染(light DOM children),须用 textContent 而非 content 属性 + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'csk-feedback-reason__btn'; btn.textContent = t(opt.labelKey); - btn.setAttribute('theme', 'default'); - btn.setAttribute('variant', 'outline'); - // button 的 click 经 eventDispose → fire 派发 CustomEvent('click'),用 addEventListener 监听 btn.addEventListener('click', (e) => { e.stopPropagation(); onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other'); @@ -1383,10 +1387,10 @@ export function showFeedbackReasonPanel( commentInput.maxLength = 200; commentRow.appendChild(commentInput); - const submitBtn = document.createElement('t-button'); + const submitBtn = document.createElement('button'); + submitBtn.type = 'button'; + submitBtn.className = 'csk-feedback-reason__submit'; submitBtn.textContent = t('feedback_reason_submit'); - submitBtn.setAttribute('theme', 'primary'); - submitBtn.setAttribute('variant', 'base'); submitBtn.addEventListener('click', (e) => { e.stopPropagation(); const comment = commentInput.value.trim() || undefined; diff --git a/client/src/i18n.ts b/client/src/i18n.ts index 078f2f6..8145b35 100644 --- a/client/src/i18n.ts +++ b/client/src/i18n.ts @@ -6,9 +6,10 @@ const dictionaries: Record> = { 'zh-CN': { // 头部 - title: 'AI 智能助手', minimize: '最小化', close: '关闭', + confirm: '确认', + cancel: '取消', status_online: '在线', role_selector_label: '服务角色', @@ -18,28 +19,17 @@ const dictionaries: Record> = { // 输入区 placeholder: '输入您的问题...', - send: '发送', - stop: '停止生成', new_message: '新消息', - // 消息操作 - copy: '复制', - copied: '已复制', - retry: '重新生成', - retrying: '重新生成中...', - // 消息 loading: '正在思考...', stream_interrupted: '回复被中断', stream_unstable: '网络不稳定,内容可能不完整', // 知识库 - category_placeholder: '选择知识库分类', category_all: '全部分类', category_load_error: '加载分类失败', - source_title: '参考来源', source_count: '{n} 条参考来源', - source_loading: '加载来源中...', // 新对话 clear: '新对话', @@ -58,8 +48,6 @@ const dictionaries: Record> = { history_group_earlier: '更早', // 消息反馈 - feedback_up: '有帮助', - feedback_down: '没帮助', feedback_reason_title: '请选择不满意的原因:', feedback_reason_inaccurate: '信息错误', feedback_reason_irrelevant: '不相关', @@ -91,9 +79,10 @@ const dictionaries: Record> = { 'en': { // Header - title: 'AI Assistant', minimize: 'Minimize', close: 'Close', + confirm: 'Confirm', + cancel: 'Cancel', status_online: 'Online', role_selector_label: 'Service', @@ -103,28 +92,17 @@ const dictionaries: Record> = { // Input placeholder: 'Type your question...', - send: 'Send', - stop: 'Stop generating', new_message: 'New message', - // Message actions - copy: 'Copy', - copied: 'Copied', - retry: 'Regenerate', - retrying: 'Regenerating...', - // Messages loading: 'Thinking...', stream_interrupted: 'Response interrupted', stream_unstable: 'Network unstable, content may be incomplete', // Knowledge base - category_placeholder: 'Select category', category_all: 'All categories', category_load_error: 'Failed to load categories', - source_title: 'Sources', source_count: '{n} source(s)', - source_loading: 'Loading sources...', // Clear/New chat clear: 'New chat', @@ -143,8 +121,6 @@ const dictionaries: Record> = { history_group_earlier: 'Earlier', // Feedback - feedback_up: 'Helpful', - feedback_down: 'Not helpful', feedback_reason_title: 'Please tell us why:', feedback_reason_inaccurate: 'Inaccurate', feedback_reason_irrelevant: 'Irrelevant', diff --git a/client/src/images.d.ts b/client/src/images.d.ts deleted file mode 100644 index 73e41b2..0000000 --- a/client/src/images.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** 图片导入类型声明(Rollup @rollup/plugin-image 会将 PNG 转为 base64 data URL) */ -declare module '*.png' { - const src: string; - export default src; -} diff --git a/client/src/index.ts b/client/src/index.ts index e18c045..2116a4d 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -9,17 +9,9 @@ */ // TDesign Web Components:聊天组件注册(side-effect import,构建时打进 IIFE) // 注意:消息气泡组件 ChatMessage 实际注册的标签是 t-chat-item(非 t-chat-message) +// 说明:chat-action / chat-loading / style 均已被 chat-message 内部 import,无需重复引入 import 'tdesign-web-components/lib/chat-message/index.js'; import 'tdesign-web-components/lib/chat-sender/index.js'; -import 'tdesign-web-components/lib/chat-action/index.js'; -import 'tdesign-web-components/lib/chat-loading/index.js'; -import 'tdesign-web-components/lib/style/index.js'; - -// TDesign Web Components:通用组件注册(Phase 2 外围 UI 替换) -import 'tdesign-web-components/lib/alert/index.js'; -import 'tdesign-web-components/lib/tag/index.js'; -import 'tdesign-web-components/lib/select/index.js'; -import 'tdesign-web-components/lib/button/index.js'; import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types'; import { parseConfig } from './config'; @@ -38,9 +30,6 @@ export type { ChatMessage, RagSource, CategoryNode, - CategoryItem, - ConversationSummary, - ConversationDetail, ChatbotSDKInstance, } from './types'; diff --git a/client/src/logger.ts b/client/src/logger.ts index 8512bde..7a1ce49 100644 --- a/client/src/logger.ts +++ b/client/src/logger.ts @@ -38,16 +38,6 @@ export const logger = { } }, - /** 开始计时(已禁用) */ - time(_label: string): void { - // 控制台日志已禁用 - }, - - /** 结束计时(已禁用) */ - timeEnd(_label: string, _prefix?: string): number { - return 0; - }, - /** 生命周期日志:init */ lifecycleInit(_integrateId: string, _requestDomain: string): void { // 控制台日志已禁用 diff --git a/client/src/styles.ts b/client/src/styles.ts index 38a30cc..05f5b65 100644 --- a/client/src/styles.ts +++ b/client/src/styles.ts @@ -644,113 +644,13 @@ function getStyles(config: ResolvedConfig): string { .csk-tooltip .t-popup__arrow { background: inherit; height: auto; } .csk-tooltip .t-popup__arrow::before { background: inherit; } -/* ===== t-tag(light DOM 组件)样式补注入 ===== - * t-tag 是 tdesign-web-components 的 light DOM 组件(isLightDOM: true),其 .t-tag - * 样式定义在 globalCSS 的 constructable stylesheet 里,但 globalCSS 只对 shadow DOM - * 生效、进不了 light DOM,导致快捷问题/建议标签"裸奔"。此处把 .t-tag 样式补进 light DOM。 - * 来源:tdesign-web-components v1.2.10 的 _chunks/dep-5245073d.js(css_248z)。 */ -.csk-root .t-tag { - font: var(--td-font-body-medium); - color: var(--td-text-color-primary); - box-sizing: border-box; - margin: 0; - padding: 0; - list-style: none; - display: inline-flex; - align-items: center; - flex-direction: row; - padding: 0px var(--td-comp-paddingLR-s); - height: var(--td-comp-size-xs); - font: var(--td-font-body-small); - color: var(--td-text-color-anti); - border-radius: var(--td-radius-default); - border: 1px solid transparent; - white-space: nowrap; -} -.csk-root .t-tag .t-icon { - margin-right: var(--td-comp-margin-xs); - width: calc(var(--td-font-size-body-medium) + 2px); - height: calc(var(--td-font-size-body-medium) + 2px); - flex-shrink: 0; -} -.csk-root .t-tag .t-tag__icon-close { - margin-right: 0; - margin-left: var(--td-comp-margin-s); - font: var(--td-font-body-medium); - cursor: pointer; - transition: color 0.2s cubic-bezier(0.38, 0, 0.24, 1); -} -.csk-root .t-tag--default { color: var(--td-text-color-primary); background-color: var(--td-bg-color-component); } -.csk-root .t-tag--default .t-tag__icon-close { color: var(--td-text-color-placeholder); } -.csk-root .t-tag--default .t-tag__icon-close:hover { color: var(--td-text-color-primary); } -.csk-root .t-tag--default:active { cursor: default; } -.csk-root .t-tag--default.t-tag--disabled { - color: var(--td-text-color-disabled); - background-color: var(--td-bg-color-component-disabled); - border-color: var(--td-component-border); - cursor: not-allowed; -} -.csk-root .t-tag--default.t-tag--disabled:hover { cursor: not-allowed; } -.csk-root .t-tag--default.t-tag--disabled .t-icon:hover { cursor: pointer; } -.csk-root .t-tag--default.t-tag--outline { border-color: var(--td-component-border); } -.csk-root .t-tag--default.t-tag--light { background-color: var(--td-bg-color-secondarycontainer); } -.csk-root .t-tag--default.t-tag--light-outline { border-color: var(--td-component-border); background-color: var(--td-bg-color-secondarycontainer); } -.csk-root .t-tag--primary { background-color: var(--td-brand-color); } -.csk-root .t-tag--primary.t-tag--outline { border-color: var(--td-brand-color); } -.csk-root .t-tag--primary.t-tag--light { background-color: var(--td-brand-color-light); } -.csk-root .t-tag--primary.t-tag--light-outline { border-color: var(--td-brand-color); background-color: var(--td-brand-color-light); } -.csk-root .t-tag--primary.t-tag--light, -.csk-root .t-tag--primary.t-tag--outline, -.csk-root .t-tag--primary.t-tag--light-outline { color: var(--td-brand-color); } -.csk-root .t-tag--success { background-color: var(--td-success-color); } -.csk-root .t-tag--success.t-tag--outline { border-color: var(--td-success-color); } -.csk-root .t-tag--success.t-tag--light { background-color: var(--td-success-color-light); } -.csk-root .t-tag--success.t-tag--light-outline { border-color: var(--td-success-color); background-color: var(--td-success-color-light); } -.csk-root .t-tag--success.t-tag--light, -.csk-root .t-tag--success.t-tag--outline, -.csk-root .t-tag--success.t-tag--light-outline { color: var(--td-success-color); } -.csk-root .t-tag--warning { background-color: var(--td-warning-color); } -.csk-root .t-tag--warning.t-tag--outline { border-color: var(--td-warning-color); } -.csk-root .t-tag--warning.t-tag--light { background-color: var(--td-warning-color-light); } -.csk-root .t-tag--warning.t-tag--light-outline { border-color: var(--td-warning-color); background-color: var(--td-warning-color-light); } -.csk-root .t-tag--warning.t-tag--light, -.csk-root .t-tag--warning.t-tag--outline, -.csk-root .t-tag--warning.t-tag--light-outline { color: var(--td-warning-color); } -.csk-root .t-tag--danger { background-color: var(--td-error-color); } -.csk-root .t-tag--danger.t-tag--outline { border-color: var(--td-error-color); } -.csk-root .t-tag--danger.t-tag--light { background-color: var(--td-error-color-light); } -.csk-root .t-tag--danger.t-tag--light-outline { border-color: var(--td-error-color); background-color: var(--td-error-color-light); } -.csk-root .t-tag--danger .t-tag__icon-close { fill: var(--td-error-color); } -.csk-root .t-tag--danger .t-tag__icon-close:hover { fill: var(--td-error-color-hover); } -.csk-root .t-tag--danger.t-tag--light, -.csk-root .t-tag--danger.t-tag--outline, -.csk-root .t-tag--danger.t-tag--light-outline { color: var(--td-error-color); } -.csk-root .t-tag.t-tag--outline { background: transparent; } -.csk-root .t-tag.t-size-s { padding: 0px var(--td-comp-paddingLR-xs); height: var(--td-comp-size-xxs); font: var(--td-font-body-small); } -.csk-root .t-tag.t-size-s .t-icon { font-size: var(--td-font-body-small); } -.csk-root .t-tag.t-size-l { padding: 0px var(--td-comp-paddingLR-m); height: var(--td-comp-size-m); font: var(--td-font-body-medium); } -.csk-root .t-tag.t-size-l .t-icon { font: var(--td-font-body-medium); } -.csk-root .t-tag.t-tag--round { border-radius: var(--td-radius-extraLarge); } -.csk-root .t-tag.t-tag--mark { border-radius: 0 var(--td-radius-extraLarge) var(--td-radius-extraLarge) 0; } -.csk-root .t-tag.t-tag--ellipsis .t-tag--text { display: inline-block; white-space: nowrap; word-wrap: normal; overflow: hidden; text-overflow: ellipsis; } -.csk-root .t-tag.t-tag--check:not(.t-tag--disabled) { transition: background-color 0.2s cubic-bezier(0.38, 0, 0.24, 1); cursor: pointer; } -.csk-root .t-tag.t-tag--check:not(.t-tag--disabled).t-tag--default.t-tag--dark:not(.t-tag--checked):hover { background-color: var(--td-bg-color-component-hover); } -.csk-root .t-tag.t-tag--check:not(.t-tag--disabled).t-tag--primary.t-tag--dark.t-tag--checked:hover { background-color: var(--td-brand-color-hover); } -.csk-root .t-tag.t-tag--check:not(.t-tag--disabled).t-tag--default.t-tag--outline:not(.t-tag--checked):hover { color: var(--td-brand-color-hover); } -.csk-root .t-tag--check.t-tag--disabled.t-tag--primary.t-tag--dark { background-color: var(--td-brand-color-disabled); cursor: not-allowed; } -.csk-root .t-tag--check.t-tag--disabled.t-tag--primary.t-tag--outline { color: var(--td-text-color-disabled); background-color: var(--td-bg-color-component-disabled); border-color: var(--td-text-color-disabled); cursor: not-allowed; } -.csk-root .t-check-tag-group .t-tag:not(:last-child) { margin-right: var(--td-comp-margin-s); } -.csk-root a .t-tag { cursor: pointer; transition: all 0.2s linear; } -.csk-root a .t-tag:hover { color: var(--td-brand-color); background-color: var(--td-bg-color-component-hover); } -.csk-root a .t-tag:active { color: var(--td-brand-color-active); background-color: var(--td-bg-color-component-active); } - /* ========== 悬浮按钮 ========== */ .csk-launcher { position: fixed; bottom: 24px; z-index: 9998; - width: 60px; - height: 60px; + width: var(--csk-launcher-size, 80px); + height: var(--csk-launcher-size, 80px); border-radius: 50%; /* 白色背景,简洁克制 */ background: #ffffff; @@ -786,8 +686,8 @@ function getStyles(config: ResolvedConfig): string { } .csk-launcher:active { transform: scale(0.93); } .csk-launcher svg { - width: 30px; - height: 30px; + width: 50%; + height: 50%; position: relative; z-index: 1; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); @@ -962,6 +862,27 @@ function getStyles(config: ResolvedConfig): string { margin: 0 4px; } +/* 原生下拉框(角色切换 + 知识库分类共用) */ +.csk-select { + padding: 4px 8px; + font-size: 12px; + font-family: inherit; + color: #1F2937; + background: #fff; + border: 1px solid var(--csk-border); + border-radius: 6px; + outline: none; + cursor: pointer; + transition: border-color 0.15s; + max-width: 150px; +} +.csk-select:focus { border-color: var(--csk-primary); } +.csk-dark .csk-select { + background: rgba(255, 255, 255, 0.06); + border-color: var(--csk-border); + color: #E2E8F0; +} + /* ========== 消息区 ========== */ .csk-messages { flex: 1; @@ -1101,12 +1022,40 @@ function getStyles(config: ResolvedConfig): string { white-space: nowrap; } +/* ========== 离线提示横幅 ========== */ +.csk-offline-banner { + margin-bottom: 10px; + padding: 8px 12px; + font-size: 12px; + color: #92400E; + background: #FEF3C7; + border: 1px solid #FCD34D; + border-radius: 8px; + line-height: 1.5; +} +.csk-dark .csk-offline-banner { + color: #FBBF24; + background: rgba(251, 191, 36, 0.12); + border-color: rgba(251, 191, 36, 0.3); +} + /* ========== P1: RAG 引用来源卡片 ========== */ .csk-sources { margin-top: 8px; max-width: 100%; font-size: 12px; } +.csk-sources__toggle { + padding: 2px 6px; + font-size: 12px; + font-family: inherit; + color: var(--csk-primary); + background: transparent; + border: none; + cursor: pointer; + transition: opacity 0.15s; +} +.csk-sources__toggle:hover { opacity: 0.8; } .csk-sources__body { margin-top: 6px; border: 1px solid var(--csk-border); @@ -1285,6 +1234,19 @@ function getStyles(config: ResolvedConfig): string { margin-top: 18px; max-width: 320px; } +.csk-quick-reply { + padding: 5px 12px; + font-size: 12px; + font-family: inherit; + color: var(--csk-primary); + background: rgba(var(--csk-primary-rgb), 0.08); + border: 1px solid var(--csk-primary); + border-radius: 999px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.14); } /* ========== 推荐问题(suggest-message-list) ========== */ .csk-suggestions { @@ -1296,6 +1258,21 @@ function getStyles(config: ResolvedConfig): string { flex-wrap: wrap; gap: 8px; } +.csk-suggestions__item { + padding: 4px 10px; + font-size: 12px; + font-family: inherit; + color: var(--csk-primary); + background: transparent; + border: 1px solid rgba(var(--csk-primary-rgb), 0.3); + border-radius: 14px; + cursor: pointer; + transition: all 0.15s; +} +.csk-suggestions__item:hover { + background: rgba(var(--csk-primary-rgb), 0.08); + border-color: var(--csk-primary); +} /* ========== 新消息提示按钮 ========== */ .csk-newmsg { @@ -1559,6 +1536,82 @@ function getStyles(config: ResolvedConfig): string { to { opacity: 1; transform: translateY(0) scale(1); } } +/* ========== 自绘确认弹窗 ========== */ +.csk-confirm-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + animation: csk-fade-in 0.15s ease; +} +.csk-confirm { + width: 300px; + max-width: calc(100vw - 48px); + background: #fff; + border-radius: 12px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); + overflow: hidden; + animation: csk-confirm-in 0.18s ease; +} +@keyframes csk-confirm-in { + from { opacity: 0; transform: translateY(8px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.csk-confirm__header { + padding: 14px 16px 0; + font-size: 14px; + font-weight: 600; + color: #1F2937; +} +.csk-confirm__body { + padding: 8px 16px 16px; + font-size: 13px; + color: #6B7280; + line-height: 1.6; + word-break: break-word; +} +.csk-confirm__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 16px 16px; +} +.csk-btn { + padding: 6px 14px; + font-size: 13px; + font-family: inherit; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + border: 1px solid transparent; +} +.csk-btn--default { + background: #fff; + border-color: var(--csk-border); + color: #374151; +} +.csk-btn--default:hover { background: #F3F4F6; } +.csk-btn--primary { + background: var(--csk-primary); + color: #fff; +} +.csk-btn--primary:hover { opacity: 0.9; } +.csk-dark .csk-confirm { + background: #1A1A2E; + border: 1px solid var(--csk-border); +} +.csk-dark .csk-confirm__header { color: #E2E8F0; } +.csk-dark .csk-confirm__body { color: #9CA3AF; } +.csk-dark .csk-btn--default { + background: rgba(255, 255, 255, 0.06); + border-color: var(--csk-border); + color: #E2E8F0; +} +.csk-dark .csk-btn--default:hover { background: rgba(255, 255, 255, 0.1); } + /* ========== 点踩原因选择面板 ========== */ .csk-feedback-reason { position: relative; diff --git a/client/src/types.ts b/client/src/types.ts index 9c86ae2..43cc94c 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -46,10 +46,10 @@ export interface SDKConfig { primaryColor?: string; /** 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 */ launcherIcon?: string; + /** 悬浮按钮尺寸:数字=px(默认 60,范围 36~96),或任意 CSS 长度字符串(如 "4rem"、"5vw"、"clamp(48px,3vw,80px)") */ + launcherSize?: number | string; /** 是否显示新对话按钮,默认 true */ showClear?: boolean; - /** 是否显示管理面板,默认 false */ - showAdminPanel?: boolean; /** 欢迎态快捷问题列表,点击即自动发送,默认空数组 */ quickReplies?: string[]; /** 是否在 AI 回复后展示推荐问题(suggest-message-list),默认 true */ @@ -128,10 +128,10 @@ export interface ResolvedConfig { primaryColor: string; /** 悬浮按钮图标 */ launcherIcon: string; + /** 悬浮按钮尺寸(number 已归一为 px,string 为 CSS 长度原文) */ + launcherSize: number | string; /** 显示新对话按钮 */ showClear: boolean; - /** 显示管理面板 */ - showAdminPanel: boolean; /** 欢迎态快捷问题列表 */ quickReplies: string[]; /** 是否在 AI 回复后展示推荐问题 */ @@ -208,33 +208,6 @@ export interface CategoryNode { children?: CategoryNode[]; } -/** 知识库分类平铺项 */ -export interface CategoryItem { - id: string; - name: string; - parentId?: string; -} - -/** 会话摘要 */ -export interface ConversationSummary { - id: string; - chatId: string; - accountId?: string; - roleId?: number; - roleName?: string; - messageCount?: number; - lastMessageTime?: number; - createdAt?: number; -} - -/** 会话详情 */ -export interface ConversationDetail { - id: string; - chatId: string; - accountId?: string; - messages: ChatMessage[]; -} - /** 本地缓存数据结构 */ export interface CacheData { messages: ChatMessage[]; diff --git a/client/tests/i18n.test.ts b/client/tests/i18n.test.ts deleted file mode 100644 index fe95898..0000000 --- a/client/tests/i18n.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * i18n.ts 国际化模块单元测试 - */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { t, setLocale, getLocale } from '../src/i18n'; - -describe('setLocale / getLocale', () => { - beforeEach(() => { - setLocale('zh-CN'); - }); - - it('默认语言为 zh-CN', () => { - expect(getLocale()).toBe('zh-CN'); - }); - - it('切换为英文', () => { - setLocale('en'); - expect(getLocale()).toBe('en'); - }); - - it('匹配语言前缀(zh → zh-CN)', () => { - setLocale('zh'); - expect(getLocale()).toBe('zh-CN'); - }); - - it('未知语言保持默认 zh-CN', () => { - setLocale('fr'); - expect(getLocale()).toBe('zh-CN'); - }); -}); - -describe('t()', () => { - beforeEach(() => { - setLocale('zh-CN'); - }); - - it('返回中文翻译', () => { - expect(t('send')).toBe('发送'); - expect(t('close')).toBe('关闭'); - }); - - it('切换英文后返回英文翻译', () => { - setLocale('en'); - expect(t('send')).toBe('Send'); - expect(t('close')).toBe('Close'); - }); - - it('未知 key 返回 key 本身', () => { - expect(t('nonexistent_key')).toBe('nonexistent_key'); - }); - - it('支持插值参数替换', () => { - setLocale('zh-CN'); - expect(t('source_count', { n: 3 })).toBe('3 条参考来源'); - }); - - it('英文插值参数替换', () => { - setLocale('en'); - expect(t('source_count', { n: 5 })).toBe('5 source(s)'); - }); - - it('中英文字典 key 数量一致', () => { - // 验证中英文字典完整性:所有中文 key 在英文中都有对应 - setLocale('zh-CN'); - const zhKeys = Object.keys(getAllKeys('zh-CN')); - setLocale('en'); - const enKeys = Object.keys(getAllKeys('en')); - - // 每个中文 key 在英文字典中也应存在 - for (const key of zhKeys) { - setLocale('en'); - const translated = t(key); - expect(translated).not.toBe(key); // 不应回退到 key 本身 - } - }); -}); - -/** 辅助函数:获取指定语言的所有 key(通过遍历测试已知 key) */ -function getAllKeys(_locale: string): Record { - const knownKeys = [ - 'title', 'minimize', 'close', 'status_online', - 'welcome_title', 'welcome_desc', - 'placeholder', 'send', 'stop', 'new_message', - 'copy', 'copied', 'retry', - 'loading', 'stream_interrupted', - 'category_all', 'source_count', - 'clear', 'clear_confirm', - 'history_title', 'history_empty', 'history_search', - 'feedback_up', 'feedback_down', - 'teaser_text', 'new_msg_announce', - 'error_network', 'error_timeout', 'error_send', - 'history_group_today', 'history_group_yesterday', - 'history_group_week', 'history_group_earlier', - ]; - const result: Record = {}; - for (const key of knownKeys) { - result[key] = t(key); - } - return result; -} diff --git a/client/tests/utils.test.ts b/client/tests/utils.test.ts deleted file mode 100644 index 0f09027..0000000 --- a/client/tests/utils.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * utils.ts 工具函数单元测试 - */ -import { describe, it, expect, vi } from 'vitest'; -import { escapeHtml, debounce, formatTime, uuid, shortUuid } from '../src/utils'; - -describe('escapeHtml', () => { - it('转义 HTML 特殊字符', () => { - expect(escapeHtml('')).toBe( - '<script>alert("xss")</script>' - ); - }); - - it('转义所有五种危险字符', () => { - expect(escapeHtml('&<>"\'')).toBe('&<>"''); - }); - - it('不修改安全文本', () => { - expect(escapeHtml('Hello World 你好世界')).toBe('Hello World 你好世界'); - }); - - it('空字符串返回空字符串', () => { - expect(escapeHtml('')).toBe(''); - }); -}); - -describe('uuid', () => { - it('生成符合 UUID v4 格式的字符串', () => { - const id = uuid(); - expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); - }); - - it('两次生成不同的值', () => { - expect(uuid()).not.toBe(uuid()); - }); -}); - -describe('shortUuid', () => { - it('生成长度为 8 的字符串', () => { - expect(shortUuid()).toHaveLength(8); - }); -}); - -describe('formatTime', () => { - it('格式化为 HH:mm', () => { - // 2026-01-15 14:30:00 UTC - const ts = new Date(2026, 0, 15, 14, 30, 0).getTime(); - expect(formatTime(ts)).toBe('14:30'); - }); - - it('补零处理', () => { - const ts = new Date(2026, 0, 1, 9, 5, 0).getTime(); - expect(formatTime(ts)).toBe('09:05'); - }); -}); - -describe('debounce', () => { - it('延迟执行函数', async () => { - vi.useFakeTimers(); - const fn = vi.fn(); - const debounced = debounce(fn, 100); - - debounced(); - expect(fn).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(100); - expect(fn).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); - }); - - it('连续调用只执行最后一次', async () => { - vi.useFakeTimers(); - const fn = vi.fn(); - const debounced = debounce(fn, 100); - - debounced(); - debounced(); - debounced(); - - vi.advanceTimersByTime(100); - expect(fn).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); - }); -});