|
|
@ -10,39 +10,12 @@ |
|
|
import { ResolvedConfig, RagSource } from './types'; |
|
|
import { ResolvedConfig, RagSource } from './types'; |
|
|
import { debounce, formatTime, formatHistoryTime } from './utils'; |
|
|
import { debounce, formatTime, formatHistoryTime } from './utils'; |
|
|
import { t } from './i18n'; |
|
|
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<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) */ |
|
|
/** 机器人头像图标(用于头部、欢迎态、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 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('role', 'button'); |
|
|
launcher.setAttribute('tabindex', '0'); |
|
|
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; |
|
|
launcher.innerHTML = config.launcherIcon; |
|
|
|
|
|
|
|
|
@ -186,7 +165,7 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
headerLeft.appendChild(headerAvatar); |
|
|
headerLeft.appendChild(headerAvatar); |
|
|
headerLeft.appendChild(headerInfo); |
|
|
headerLeft.appendChild(headerInfo); |
|
|
|
|
|
|
|
|
// === 角色选择器(仅当有多个角色时显示,t-select) ===
|
|
|
|
|
|
|
|
|
// === 角色选择器(仅当有多个角色时显示,原生 select) ===
|
|
|
let roleSelect: HTMLElement | null = null; |
|
|
let roleSelect: HTMLElement | null = null; |
|
|
const roles = config.roles; |
|
|
const roles = config.roles; |
|
|
if (roles && roles.length > 1) { |
|
|
if (roles && roles.length > 1) { |
|
|
@ -194,21 +173,22 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
const roleWrap = document.createElement('div'); |
|
|
const roleWrap = document.createElement('div'); |
|
|
roleWrap.className = 'csk-role-select-wrap'; |
|
|
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.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(headerLeft); |
|
|
header.appendChild(roleWrap); |
|
|
header.appendChild(roleWrap); |
|
|
@ -262,19 +242,19 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
<div class="csk-welcome__desc">${t('welcome_desc')}</div> |
|
|
<div class="csk-welcome__desc">${t('welcome_desc')}</div> |
|
|
`;
|
|
|
`;
|
|
|
|
|
|
|
|
|
// 快捷问题芯片(点击即自动发送,由 t-tag 承载)
|
|
|
|
|
|
|
|
|
// 快捷问题芯片(点击即自动发送,原生 button)
|
|
|
if (config.quickReplies.length > 0) { |
|
|
if (config.quickReplies.length > 0) { |
|
|
const chips = document.createElement('div'); |
|
|
const chips = document.createElement('div'); |
|
|
chips.className = 'csk-quick-replies'; |
|
|
chips.className = 'csk-quick-replies'; |
|
|
for (const reply of config.quickReplies) { |
|
|
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); |
|
|
welcomeEl.appendChild(chips); |
|
|
} |
|
|
} |
|
|
@ -329,7 +309,7 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
} |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
// === 知识库分类下拉框(t-select,选项由 loadCategories 异步填充) ===
|
|
|
|
|
|
|
|
|
// === 知识库分类下拉框(原生 select,选项由 loadCategories 异步填充) ===
|
|
|
let categorySelect: HTMLElement | null = null; |
|
|
let categorySelect: HTMLElement | null = null; |
|
|
if (config.showCategorySwitch) { |
|
|
if (config.showCategorySwitch) { |
|
|
const categoryBar = document.createElement('div'); |
|
|
const categoryBar = document.createElement('div'); |
|
|
@ -340,22 +320,18 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
categoryLabel.textContent = '📚'; |
|
|
categoryLabel.textContent = '📚'; |
|
|
categoryBar.appendChild(categoryLabel); |
|
|
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'; |
|
|
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 之间
|
|
|
// 插入到 messages 和 inputArea 之间
|
|
|
windowEl.appendChild(header); |
|
|
windowEl.appendChild(header); |
|
|
@ -554,9 +530,9 @@ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragE |
|
|
|
|
|
|
|
|
/** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */ |
|
|
/** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */ |
|
|
const LAUNCHER_MIN_BOTTOM = 16; |
|
|
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) { |
|
|
if (hasMoved) { |
|
|
e.preventDefault(); // 阻止触摸滚动
|
|
|
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`; |
|
|
launcherEl.style.bottom = `${newBottom}px`; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
@ -641,7 +617,7 @@ export function enableLauncherDrag( |
|
|
|
|
|
|
|
|
// 隐藏拖拽态(保留当前 bottom)
|
|
|
// 隐藏拖拽态(保留当前 bottom)
|
|
|
const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom(); |
|
|
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(); |
|
|
const rect = launcherEl.getBoundingClientRect(); |
|
|
@ -966,11 +942,8 @@ export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): voi |
|
|
// ==================== P1: RAG 引用来源渲染 ====================
|
|
|
// ==================== P1: RAG 引用来源渲染 ====================
|
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 渲染 RAG 引用来源卡片(t-button 折叠开关 + 自定义来源列表) |
|
|
|
|
|
* 说明:尝试用 <t-collapse> 替换,但 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 { |
|
|
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'); |
|
|
const sourcesEl = document.createElement('div'); |
|
|
sourcesEl.className = 'csk-sources csk-sources--collapsed'; |
|
|
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.className = 'csk-sources__toggle'; |
|
|
toggle.setAttribute('theme', 'default'); |
|
|
|
|
|
toggle.setAttribute('variant', 'text'); |
|
|
|
|
|
toggle.setAttribute('size', 'small'); |
|
|
|
|
|
|
|
|
|
|
|
const updateToggleLabel = () => { |
|
|
const updateToggleLabel = () => { |
|
|
const collapsed = sourcesEl.classList.contains('csk-sources--collapsed'); |
|
|
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 { |
|
|
export function showOfflineBanner(messagesContainer: HTMLElement): void { |
|
|
if (document.getElementById('csk-offline-banner')) return; |
|
|
if (document.getElementById('csk-offline-banner')) return; |
|
|
const banner = document.createElement('t-alert'); |
|
|
|
|
|
|
|
|
const banner = document.createElement('div'); |
|
|
banner.id = 'csk-offline-banner'; |
|
|
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); |
|
|
messagesContainer.insertBefore(banner, messagesContainer.firstChild); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -1259,12 +1229,10 @@ export function renderSuggestions( |
|
|
list.className = 'csk-suggestions__list'; |
|
|
list.className = 'csk-suggestions__list'; |
|
|
|
|
|
|
|
|
for (const text of suggestions) { |
|
|
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.textContent = text; |
|
|
btn.setAttribute('theme', 'default'); |
|
|
|
|
|
btn.setAttribute('variant', 'text'); |
|
|
|
|
|
btn.setAttribute('size', 'small'); |
|
|
|
|
|
btn.addEventListener('click', (e) => { |
|
|
btn.addEventListener('click', (e) => { |
|
|
e.stopPropagation(); |
|
|
e.stopPropagation(); |
|
|
onClick(text); |
|
|
onClick(text); |
|
|
@ -1280,7 +1248,7 @@ export function renderSuggestions( |
|
|
/** |
|
|
/** |
|
|
* 移除指定 wrapper 中的建议问题列表 |
|
|
* 移除指定 wrapper 中的建议问题列表 |
|
|
*/ |
|
|
*/ |
|
|
export function removeSuggestions(wrapper: HTMLElement): void { |
|
|
|
|
|
|
|
|
function removeSuggestions(wrapper: HTMLElement): void { |
|
|
const existing = wrapper.querySelector('.csk-suggestions'); |
|
|
const existing = wrapper.querySelector('.csk-suggestions'); |
|
|
if (existing) existing.remove(); |
|
|
if (existing) existing.remove(); |
|
|
} |
|
|
} |
|
|
@ -1296,44 +1264,82 @@ const REASON_OPTIONS: { key: string; labelKey: string }[] = [ |
|
|
]; |
|
|
]; |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 命令式确认弹窗(DialogPlugin.confirm),替代原生 confirm() |
|
|
|
|
|
|
|
|
* 命令式确认弹窗(自绘模态,替代原生 confirm()) |
|
|
* @returns 确认返回 true,取消/关闭返回 false |
|
|
* @returns 确认返回 true,取消/关闭返回 false |
|
|
*/ |
|
|
*/ |
|
|
export function confirmWithDialog(message: string): Promise<boolean> { |
|
|
export function confirmWithDialog(message: string): Promise<boolean> { |
|
|
return new Promise((resolve) => { |
|
|
return new Promise((resolve) => { |
|
|
let settled = false; |
|
|
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; |
|
|
if (settled) return; |
|
|
settled = true; |
|
|
settled = true; |
|
|
|
|
|
cleanup(); |
|
|
resolve(val); |
|
|
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); |
|
|
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( |
|
|
export function showFeedbackReasonPanel( |
|
|
wrapper: HTMLElement, |
|
|
wrapper: HTMLElement, |
|
|
@ -1354,16 +1360,14 @@ export function showFeedbackReasonPanel( |
|
|
title.textContent = t('feedback_reason_title'); |
|
|
title.textContent = t('feedback_reason_title'); |
|
|
panel.appendChild(title); |
|
|
panel.appendChild(title); |
|
|
|
|
|
|
|
|
// 原因选项按钮(t-button,点击即确认)
|
|
|
|
|
|
|
|
|
// 原因选项按钮(原生 button,点击即确认)
|
|
|
const optionsRow = document.createElement('div'); |
|
|
const optionsRow = document.createElement('div'); |
|
|
optionsRow.className = 'csk-feedback-reason__options'; |
|
|
optionsRow.className = 'csk-feedback-reason__options'; |
|
|
REASON_OPTIONS.forEach((opt) => { |
|
|
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.textContent = t(opt.labelKey); |
|
|
btn.setAttribute('theme', 'default'); |
|
|
|
|
|
btn.setAttribute('variant', 'outline'); |
|
|
|
|
|
// button 的 click 经 eventDispose → fire 派发 CustomEvent('click'),用 addEventListener 监听
|
|
|
|
|
|
btn.addEventListener('click', (e) => { |
|
|
btn.addEventListener('click', (e) => { |
|
|
e.stopPropagation(); |
|
|
e.stopPropagation(); |
|
|
onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other'); |
|
|
onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other'); |
|
|
@ -1383,10 +1387,10 @@ export function showFeedbackReasonPanel( |
|
|
commentInput.maxLength = 200; |
|
|
commentInput.maxLength = 200; |
|
|
commentRow.appendChild(commentInput); |
|
|
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.textContent = t('feedback_reason_submit'); |
|
|
submitBtn.setAttribute('theme', 'primary'); |
|
|
|
|
|
submitBtn.setAttribute('variant', 'base'); |
|
|
|
|
|
submitBtn.addEventListener('click', (e) => { |
|
|
submitBtn.addEventListener('click', (e) => { |
|
|
e.stopPropagation(); |
|
|
e.stopPropagation(); |
|
|
const comment = commentInput.value.trim() || undefined; |
|
|
const comment = commentInput.value.trim() || undefined; |
|
|
|