Browse Source

refactor(client): 外围组件原生化并清理死代码,缩小 SDK 体积

- 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 冲突
master
wanghanlin 4 days ago
parent
commit
83964afcfc
  1. 45
      client/README.md
  2. 25
      client/src/chat.ts
  3. 17
      client/src/config.ts
  4. 256
      client/src/dom.ts
  5. 32
      client/src/i18n.ts
  6. 5
      client/src/images.d.ts
  7. 13
      client/src/index.ts
  8. 10
      client/src/logger.ts
  9. 261
      client/src/styles.ts
  10. 35
      client/src/types.ts
  11. 100
      client/tests/i18n.test.ts
  12. 86
      client/tests/utils.test.ts

45
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`)、更多语言、主题皮肤 |
| **可选扩展** | 🔜 待定 | 知识库管理嵌入、更多语言、主题皮肤 |
---

25
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<void> {
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<void> {
// 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<void> {
*
* @param conversationId ID chatId
*/
export async function switchToConversation(conversationId: string): Promise<void> {
async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return;
// 找到该会话对应的历史条目,获取其所属角色
@ -906,7 +908,7 @@ export async function switchToConversation(conversationId: string): Promise<void
setActiveRoleId(convRoleId);
// 更新角色下拉框选中项
if (roleSelect) {
(roleSelect as unknown as { value: string }).value = convRoleId;
(roleSelect as HTMLSelectElement).value = convRoleId;
}
}
}
@ -955,11 +957,6 @@ export async function switchToConversation(conversationId: string): Promise<void
updateEmptyState();
}
/** 获取当前消息列表 */
export function getMessages(): ChatMessage[] {
return messages;
}
// ==================== 建议问题(suggest-message-list) ====================
/**

17
client/src/config.ts

@ -9,6 +9,15 @@
import { SDKConfig, ResolvedConfig } from './types';
import { logger } from './logger';
/**
* CSS + clamp()/calc()/min()/max()
* launcherSize 退 60
*/
function isCssLength(value: string): boolean {
return /^-?\d*\.?\d+(px|rem|em|vw|vh|vmin|vmax|%|ch|ex|pt|pc|cm|mm|in)$/.test(value)
|| /^(clamp|calc|min|max)\(/.test(value);
}
/**
*
@ -42,6 +51,12 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
const domain = raw.requestDomain.replace(/\/+$/, '');
const resolvedLauncherIcon = raw.launcherIcon || `<img src="${domain}/sdk/launcher-logo.png" alt="" draggable="false" />`;
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)
: [],

256
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
* omitdesign-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<string, unknown>,
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`;
/** 用户头像图标 */
const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
@ -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): {
<div class="csk-welcome__desc">${t('welcome_desc')}</div>
`;
// 快捷问题芯片(点击即自动发送,由 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 +
* <t-collapse> tdesign-web-components collapse/collapse-panel
* omi provide/inject DOMcreateElement + 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<boolean> {
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
* <t-dialog> 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 文本通过 <slot> 渲染(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;

32
client/src/i18n.ts

@ -6,9 +6,10 @@
const dictionaries: Record<string, Record<string, string>> = {
'zh-CN': {
// 头部
title: 'AI 智能助手',
minimize: '最小化',
close: '关闭',
confirm: '确认',
cancel: '取消',
status_online: '在线',
role_selector_label: '服务角色',
@ -18,28 +19,17 @@ const dictionaries: Record<string, Record<string, string>> = {
// 输入区
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<string, Record<string, string>> = {
history_group_earlier: '更早',
// 消息反馈
feedback_up: '有帮助',
feedback_down: '没帮助',
feedback_reason_title: '请选择不满意的原因:',
feedback_reason_inaccurate: '信息错误',
feedback_reason_irrelevant: '不相关',
@ -91,9 +79,10 @@ const dictionaries: Record<string, Record<string, string>> = {
'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<string, Record<string, string>> = {
// 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<string, Record<string, string>> = {
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',

5
client/src/images.d.ts

@ -1,5 +0,0 @@
/** 图片导入类型声明(Rollup @rollup/plugin-image 会将 PNG 转为 base64 data URL) */
declare module '*.png' {
const src: string;
export default src;
}

13
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';

10
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 {
// 控制台日志已禁用

261
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-taglight 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.jscss_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;

35
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[];

100
client/tests/i18n.test.ts

@ -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<string, string> {
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<string, string> = {};
for (const key of knownKeys) {
result[key] = t(key);
}
return result;
}

86
client/tests/utils.test.ts

@ -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('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});
it('转义所有五种危险字符', () => {
expect(escapeHtml('&<>"\'')).toBe('&amp;&lt;&gt;&quot;&#x27;');
});
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();
});
});
Loading…
Cancel
Save