|
|
|
@ -10,6 +10,30 @@ |
|
|
|
import { ResolvedConfig, RagSource } from './types'; |
|
|
|
import { debounce, formatTime } 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<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; |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 图标常量 ====================
|
|
|
|
|
|
|
|
@ -101,11 +125,10 @@ function createWatermarkPattern(config: ResolvedConfig): string { |
|
|
|
export function createChatWindow(config: ResolvedConfig): { |
|
|
|
window: HTMLElement; |
|
|
|
messagesContainer: HTMLElement; |
|
|
|
inputEl: HTMLTextAreaElement; |
|
|
|
sendBtn: HTMLElement; |
|
|
|
inputEl: HTMLElement; |
|
|
|
clearBtn: HTMLElement | null; |
|
|
|
categorySelect: HTMLSelectElement | null; |
|
|
|
roleSelect: HTMLSelectElement | null; |
|
|
|
categorySelect: HTMLElement | null; |
|
|
|
roleSelect: HTMLElement | null; |
|
|
|
historyPanel: HTMLElement; |
|
|
|
welcomeEl: HTMLElement; |
|
|
|
newMsgBtn: HTMLElement; |
|
|
|
@ -163,36 +186,30 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
headerLeft.appendChild(headerAvatar); |
|
|
|
headerLeft.appendChild(headerInfo); |
|
|
|
|
|
|
|
// === 角色选择器(仅当有多个角色时显示) ===
|
|
|
|
let roleSelect: HTMLSelectElement | null = null; |
|
|
|
// === 角色选择器(仅当有多个角色时显示,t-select) ===
|
|
|
|
let roleSelect: HTMLElement | null = null; |
|
|
|
const roles = config.roles; |
|
|
|
if (roles && roles.length > 1) { |
|
|
|
roleSelect = document.createElement('select'); |
|
|
|
roleSelect.className = 'csk-role-select'; |
|
|
|
roleSelect.setAttribute('aria-label', t('role_selector_label')); |
|
|
|
// 包装后插入到 headerLeft 之后、actions 之前
|
|
|
|
const roleWrap = document.createElement('div'); |
|
|
|
roleWrap.className = 'csk-role-select-wrap'; |
|
|
|
|
|
|
|
for (const role of roles) { |
|
|
|
const option = document.createElement('option'); |
|
|
|
option.value = String(role.id); |
|
|
|
option.textContent = role.name || String(role.id); |
|
|
|
if (String(role.id) === String(config.integrateId)) { |
|
|
|
option.selected = true; |
|
|
|
// 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); |
|
|
|
} |
|
|
|
roleSelect.appendChild(option); |
|
|
|
} |
|
|
|
|
|
|
|
// onChange 触发自定义事件
|
|
|
|
roleSelect.addEventListener('change', () => { |
|
|
|
const selectedValue = roleSelect!.value; |
|
|
|
windowEl.dispatchEvent(new CustomEvent('csk:roleChange', { |
|
|
|
detail: { roleId: selectedValue } |
|
|
|
detail: { roleId: String(value) } |
|
|
|
})); |
|
|
|
}); |
|
|
|
}, |
|
|
|
}, roleWrap); |
|
|
|
roleSelect.setAttribute('aria-label', t('role_selector_label')); |
|
|
|
|
|
|
|
// 包装后插入到 headerLeft 之后、actions 之前
|
|
|
|
const roleWrap = document.createElement('div'); |
|
|
|
roleWrap.className = 'csk-role-select-wrap'; |
|
|
|
roleWrap.appendChild(roleSelect); |
|
|
|
header.appendChild(headerLeft); |
|
|
|
header.appendChild(roleWrap); |
|
|
|
} else { |
|
|
|
@ -245,19 +262,19 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
<div class="csk-welcome__desc">${t('welcome_desc')}</div> |
|
|
|
`;
|
|
|
|
|
|
|
|
// 快捷问题芯片(点击即自动发送)
|
|
|
|
// 快捷问题芯片(点击即自动发送,由 t-tag 承载)
|
|
|
|
if (config.quickReplies.length > 0) { |
|
|
|
const chips = document.createElement('div'); |
|
|
|
chips.className = 'csk-quick-replies'; |
|
|
|
for (const reply of config.quickReplies) { |
|
|
|
const chip = document.createElement('button'); |
|
|
|
chip.type = 'button'; |
|
|
|
chip.className = 'csk-quick-reply'; |
|
|
|
chip.textContent = reply; |
|
|
|
chip.addEventListener('click', () => { |
|
|
|
// 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.appendChild(chip); |
|
|
|
}, |
|
|
|
}, chips, reply); |
|
|
|
} |
|
|
|
welcomeEl.appendChild(chips); |
|
|
|
} |
|
|
|
@ -312,8 +329,8 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
// === 知识库分类下拉框(P1) ===
|
|
|
|
let categorySelect: HTMLSelectElement | null = null; |
|
|
|
// === 知识库分类下拉框(t-select,选项由 loadCategories 异步填充) ===
|
|
|
|
let categorySelect: HTMLElement | null = null; |
|
|
|
if (config.showCategorySwitch) { |
|
|
|
const categoryBar = document.createElement('div'); |
|
|
|
categoryBar.className = 'csk-category-bar'; |
|
|
|
@ -321,22 +338,24 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
const categoryLabel = document.createElement('span'); |
|
|
|
categoryLabel.className = 'csk-category-bar__label'; |
|
|
|
categoryLabel.textContent = '📚'; |
|
|
|
categoryBar.appendChild(categoryLabel); |
|
|
|
|
|
|
|
categorySelect = document.createElement('select'); |
|
|
|
categorySelect.id = 'csk-category-select'; |
|
|
|
categorySelect.className = 'csk-category-select'; |
|
|
|
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`; |
|
|
|
|
|
|
|
// onChange 触发自定义事件
|
|
|
|
categorySelect.addEventListener('change', () => { |
|
|
|
const selectedId = categorySelect!.value; |
|
|
|
// 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.appendChild(categoryLabel); |
|
|
|
categoryBar.appendChild(categorySelect); |
|
|
|
}, |
|
|
|
}, categoryBar); |
|
|
|
categorySelect.id = 'csk-category-select'; |
|
|
|
|
|
|
|
// 插入到 messages 和 inputArea 之间
|
|
|
|
windowEl.appendChild(header); |
|
|
|
@ -351,32 +370,19 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
const inputArea = document.createElement('div'); |
|
|
|
inputArea.className = 'csk-input-area'; |
|
|
|
|
|
|
|
const inputWrap = document.createElement('div'); |
|
|
|
inputWrap.className = 'csk-input-wrap'; |
|
|
|
|
|
|
|
const inputEl = document.createElement('textarea'); |
|
|
|
// 输入框 + 发送/停止按钮统一由 t-chat-sender 承担(loading 控制 send/stop 切换,Enter 发送已内置)
|
|
|
|
const inputEl = document.createElement('t-chat-sender'); |
|
|
|
inputEl.id = 'csk-input'; |
|
|
|
inputEl.className = 'csk-input'; |
|
|
|
inputEl.setAttribute('placeholder', t('placeholder')); |
|
|
|
inputEl.setAttribute('rows', '1'); |
|
|
|
inputEl.setAttribute('autofocus', ''); |
|
|
|
|
|
|
|
const sendBtn = document.createElement('button'); |
|
|
|
sendBtn.id = 'csk-send-btn'; |
|
|
|
sendBtn.className = 'csk-send-btn'; |
|
|
|
sendBtn.setAttribute('title', t('send')); |
|
|
|
sendBtn.setAttribute('disabled', 'true'); |
|
|
|
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`; |
|
|
|
|
|
|
|
inputWrap.appendChild(inputEl); |
|
|
|
inputWrap.appendChild(sendBtn); |
|
|
|
inputArea.appendChild(inputWrap); |
|
|
|
inputArea.appendChild(inputEl); |
|
|
|
|
|
|
|
// === 保密声明脚注 ===
|
|
|
|
const disclaimer = document.createElement('div'); |
|
|
|
disclaimer.className = 'csk-disclaimer'; |
|
|
|
if (config.disclaimer !== undefined) { |
|
|
|
// 用户自定义或传空串隐藏
|
|
|
|
// 用户自定义或传空串隐藏。
|
|
|
|
// 注意:disclaimer 语义为「受信 HTML」(由宿主开发者或后端管理员配置),
|
|
|
|
// 此处直接 innerHTML 注入属设计内行为,请勿传入不可信用户输入。
|
|
|
|
disclaimer.innerHTML = config.disclaimer; |
|
|
|
if (!config.disclaimer) { |
|
|
|
disclaimer.style.display = 'none'; |
|
|
|
@ -404,19 +410,12 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
|
|
|
|
function showLoading(): HTMLElement { |
|
|
|
if (loadingEl) { |
|
|
|
loadingEl.style.display = 'flex'; |
|
|
|
loadingEl.style.display = ''; |
|
|
|
return loadingEl; |
|
|
|
} |
|
|
|
const el = document.createElement('div'); |
|
|
|
el.className = 'csk-loading'; |
|
|
|
el.innerHTML = `
|
|
|
|
<div class="csk-loading__avatar">${BOT_ICON}</div> |
|
|
|
<div class="csk-loading__bubble"> |
|
|
|
<div class="csk-loading__dot"></div> |
|
|
|
<div class="csk-loading__dot"></div> |
|
|
|
<div class="csk-loading__dot"></div> |
|
|
|
</div> |
|
|
|
`;
|
|
|
|
const el = document.createElement('t-chat-loading'); |
|
|
|
el.setAttribute('animation', 'dots'); |
|
|
|
el.setAttribute('text', t('loading')); |
|
|
|
messagesContainer.appendChild(el); |
|
|
|
loadingEl = el; |
|
|
|
return el; |
|
|
|
@ -443,10 +442,16 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
const teaserEl = document.createElement('div'); |
|
|
|
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`; |
|
|
|
teaserEl.setAttribute('role', 'status'); |
|
|
|
teaserEl.innerHTML = `
|
|
|
|
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">×</button> |
|
|
|
<span>${config.teaserText || t('teaser_text')}</span> |
|
|
|
`;
|
|
|
|
const teaserCloseBtn = document.createElement('button'); |
|
|
|
teaserCloseBtn.className = 'csk-teaser__close'; |
|
|
|
teaserCloseBtn.setAttribute('aria-label', t('close')); |
|
|
|
teaserCloseBtn.type = 'button'; |
|
|
|
teaserCloseBtn.textContent = '×'; |
|
|
|
const teaserTextSpan = document.createElement('span'); |
|
|
|
// 文本语义:teaserText 使用 textContent 注入,避免宿主传入未信任内容时构成 XSS
|
|
|
|
teaserTextSpan.textContent = config.teaserText || t('teaser_text'); |
|
|
|
teaserEl.appendChild(teaserCloseBtn); |
|
|
|
teaserEl.appendChild(teaserTextSpan); |
|
|
|
|
|
|
|
// === 缩放拖拽手柄(右下角) ===
|
|
|
|
const resizeHandle = document.createElement('div'); |
|
|
|
@ -460,7 +465,6 @@ export function createChatWindow(config: ResolvedConfig): { |
|
|
|
window: windowEl, |
|
|
|
messagesContainer, |
|
|
|
inputEl, |
|
|
|
sendBtn, |
|
|
|
clearBtn, |
|
|
|
categorySelect, |
|
|
|
historyPanel, |
|
|
|
@ -812,278 +816,145 @@ export function enableResize( |
|
|
|
// ==================== 消息渲染 ====================
|
|
|
|
|
|
|
|
/** 渲染用户消息气泡 */ |
|
|
|
/** 创建一个 t-chat-item 消息元素(TDesign Chat 消息气泡,实际标签为 t-chat-item) */ |
|
|
|
function createChatItemMsg(role: 'user' | 'assistant', content: unknown[], timestamp: number, msgId?: string): HTMLElement { |
|
|
|
const msg = document.createElement('t-chat-item'); |
|
|
|
// omi 组件约定:复杂类型(Array/Object)用 property 赋值;简单类型(String/Boolean)用 setAttribute
|
|
|
|
msg.setAttribute('role', role); |
|
|
|
msg.setAttribute('datetime', formatTime(timestamp)); |
|
|
|
if (msgId) msg.setAttribute('id', msgId); |
|
|
|
(msg as unknown as { content: unknown[] }).content = content; |
|
|
|
if (role === 'user') { |
|
|
|
msg.setAttribute('placement', 'right'); |
|
|
|
msg.setAttribute('variant', 'base'); |
|
|
|
} else { |
|
|
|
msg.setAttribute('placement', 'left'); |
|
|
|
msg.setAttribute('variant', 'text'); |
|
|
|
} |
|
|
|
return msg; |
|
|
|
} |
|
|
|
|
|
|
|
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement { |
|
|
|
const wrapper = document.createElement('div'); |
|
|
|
wrapper.className = 'csk-msg csk-msg--user'; |
|
|
|
|
|
|
|
const avatar = document.createElement('div'); |
|
|
|
avatar.className = 'csk-msg__avatar csk-msg__avatar--user'; |
|
|
|
avatar.innerHTML = USER_ICON; |
|
|
|
|
|
|
|
const content = document.createElement('div'); |
|
|
|
content.className = 'csk-msg__content'; |
|
|
|
wrapper.appendChild(createChatItemMsg('user', [{ type: 'text', data: text }], timestamp)); |
|
|
|
|
|
|
|
const bubble = document.createElement('div'); |
|
|
|
bubble.className = 'csk-msg__bubble'; |
|
|
|
bubble.textContent = text; |
|
|
|
container.appendChild(wrapper); |
|
|
|
|
|
|
|
const time = document.createElement('div'); |
|
|
|
time.className = 'csk-msg__time'; |
|
|
|
time.textContent = formatTime(timestamp); |
|
|
|
return wrapper; |
|
|
|
} |
|
|
|
|
|
|
|
content.appendChild(time); |
|
|
|
content.appendChild(bubble); |
|
|
|
wrapper.appendChild(avatar); |
|
|
|
wrapper.appendChild(content); |
|
|
|
/** 渲染错误气泡(发送失败提示,用 t-chat-item 的 error 状态承载) */ |
|
|
|
export function renderErrorBubble(container: HTMLElement, errMsg: string, timestamp: number): HTMLElement { |
|
|
|
const wrapper = document.createElement('div'); |
|
|
|
wrapper.className = 'csk-msg csk-msg--ai'; |
|
|
|
const msg = createChatItemMsg('assistant', [{ type: 'text', data: `⚠ ${errMsg}` }], timestamp); |
|
|
|
msg.setAttribute('status', 'error'); |
|
|
|
wrapper.appendChild(msg); |
|
|
|
container.appendChild(wrapper); |
|
|
|
|
|
|
|
return wrapper; |
|
|
|
} |
|
|
|
|
|
|
|
/** 渲染 AI 消息气泡(支持 Markdown) */ |
|
|
|
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string, msgId?: string): HTMLElement { |
|
|
|
/** 渲染 AI 消息气泡(Markdown 由 t-chat-item 内置 cherry-markdown 渲染) */ |
|
|
|
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, msgId?: string, feedback?: 'up' | 'down'): HTMLElement { |
|
|
|
const wrapper = document.createElement('div'); |
|
|
|
wrapper.className = 'csk-msg csk-msg--ai'; |
|
|
|
if (msgId) wrapper.dataset.cskMsgId = msgId; |
|
|
|
|
|
|
|
const avatar = document.createElement('div'); |
|
|
|
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai'; |
|
|
|
avatar.innerHTML = BOT_ICON; |
|
|
|
|
|
|
|
const content = document.createElement('div'); |
|
|
|
content.className = 'csk-msg__content'; |
|
|
|
|
|
|
|
const bubble = document.createElement('div'); |
|
|
|
bubble.className = 'csk-msg__bubble'; |
|
|
|
// 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本
|
|
|
|
if (renderMd) { |
|
|
|
bubble.innerHTML = renderMd(text); |
|
|
|
} else { |
|
|
|
bubble.textContent = text; |
|
|
|
} |
|
|
|
const msg = createChatItemMsg('assistant', [{ type: 'markdown', data: text }], timestamp, msgId); |
|
|
|
msg.setAttribute('status', 'complete'); |
|
|
|
// 操作栏投射到 t-chat-item 的 actionbar 插槽
|
|
|
|
msg.appendChild(createChatAction(wrapper, text, msgId, feedback)); |
|
|
|
wrapper.appendChild(msg); |
|
|
|
|
|
|
|
const time = document.createElement('div'); |
|
|
|
time.className = 'csk-msg__time'; |
|
|
|
time.textContent = formatTime(timestamp); |
|
|
|
|
|
|
|
content.appendChild(time); |
|
|
|
content.appendChild(bubble); |
|
|
|
// 操作栏:位于气泡下方
|
|
|
|
const meta = document.createElement('div'); |
|
|
|
meta.className = 'csk-msg__meta'; |
|
|
|
meta.appendChild(buildAIActions(bubble, wrapper)); |
|
|
|
content.appendChild(meta); |
|
|
|
wrapper.appendChild(avatar); |
|
|
|
wrapper.appendChild(content); |
|
|
|
container.appendChild(wrapper); |
|
|
|
|
|
|
|
// 代码块复制按钮
|
|
|
|
enhanceCodeBlocks(bubble); |
|
|
|
|
|
|
|
return wrapper; |
|
|
|
} |
|
|
|
|
|
|
|
/** 创建空的 AI 气泡(流式追加用) */ |
|
|
|
export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } { |
|
|
|
const wrapper = document.createElement('div'); |
|
|
|
wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming'; |
|
|
|
wrapper.className = 'csk-msg csk-msg--ai'; |
|
|
|
if (msgId) wrapper.dataset.cskMsgId = msgId; |
|
|
|
|
|
|
|
const avatar = document.createElement('div'); |
|
|
|
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai'; |
|
|
|
avatar.innerHTML = BOT_ICON; |
|
|
|
|
|
|
|
const content = document.createElement('div'); |
|
|
|
content.className = 'csk-msg__content'; |
|
|
|
|
|
|
|
const bubble = document.createElement('div'); |
|
|
|
bubble.className = 'csk-msg__bubble'; |
|
|
|
bubble.innerHTML = ''; |
|
|
|
const msg = createChatItemMsg('assistant', [], timestamp, msgId); |
|
|
|
msg.setAttribute('status', 'streaming'); |
|
|
|
// 操作栏投射到 t-chat-item 的 actionbar 插槽(流式期间 copyText 为空,结束由 finalizeAIBubble 补全)
|
|
|
|
msg.appendChild(createChatAction(wrapper, '', msgId)); |
|
|
|
wrapper.appendChild(msg); |
|
|
|
|
|
|
|
const time = document.createElement('div'); |
|
|
|
time.className = 'csk-msg__time'; |
|
|
|
time.textContent = formatTime(timestamp); |
|
|
|
|
|
|
|
content.appendChild(time); |
|
|
|
content.appendChild(bubble); |
|
|
|
// 操作栏:位于气泡下方
|
|
|
|
const meta = document.createElement('div'); |
|
|
|
meta.className = 'csk-msg__meta'; |
|
|
|
meta.appendChild(buildAIActions(bubble, wrapper)); |
|
|
|
content.appendChild(meta); |
|
|
|
wrapper.appendChild(avatar); |
|
|
|
wrapper.appendChild(content); |
|
|
|
container.appendChild(wrapper); |
|
|
|
|
|
|
|
return { wrapper, bubble }; |
|
|
|
return { wrapper, bubble: msg }; |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== AI 消息操作条 + 代码块复制 ====================
|
|
|
|
|
|
|
|
/** 复制图标 */ |
|
|
|
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; |
|
|
|
/** 重试图标 */ |
|
|
|
const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`; |
|
|
|
|
|
|
|
/** 构建 AI 消息操作条(复制 / 重试 / 反馈) */ |
|
|
|
function buildAIActions(bubble: HTMLElement, wrapper: HTMLElement): HTMLElement { |
|
|
|
const bar = document.createElement('div'); |
|
|
|
bar.className = 'csk-msg__actions'; |
|
|
|
|
|
|
|
// 复制按钮
|
|
|
|
const copyBtn = document.createElement('button'); |
|
|
|
copyBtn.type = 'button'; |
|
|
|
copyBtn.className = 'csk-action-btn'; |
|
|
|
copyBtn.setAttribute('title', t('copy')); |
|
|
|
copyBtn.setAttribute('aria-label', t('copy')); |
|
|
|
copyBtn.innerHTML = COPY_ICON; |
|
|
|
copyBtn.addEventListener('click', async (e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
const text = bubble.textContent || ''; |
|
|
|
await copyToClipboard(text, copyBtn); |
|
|
|
}); |
|
|
|
|
|
|
|
// 重试按钮
|
|
|
|
const retryBtn = document.createElement('button'); |
|
|
|
retryBtn.type = 'button'; |
|
|
|
retryBtn.className = 'csk-action-btn'; |
|
|
|
retryBtn.setAttribute('title', t('retry')); |
|
|
|
retryBtn.setAttribute('aria-label', t('retry')); |
|
|
|
retryBtn.innerHTML = RETRY_ICON; |
|
|
|
retryBtn.addEventListener('click', (e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
const root = wrapper.closest('.csk-window'); |
|
|
|
if (root) { |
|
|
|
root.dispatchEvent(new CustomEvent('csk:retry', { |
|
|
|
detail: { msgId: wrapper.dataset.cskMsgId || '' } |
|
|
|
})); |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
// 反馈按钮(👍 / 👎)
|
|
|
|
const THUMB_UP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`; |
|
|
|
const THUMB_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>`; |
|
|
|
|
|
|
|
const upBtn = document.createElement('button'); |
|
|
|
upBtn.type = 'button'; |
|
|
|
upBtn.className = 'csk-feedback-btn'; |
|
|
|
upBtn.setAttribute('title', t('feedback_up')); |
|
|
|
upBtn.setAttribute('aria-label', t('feedback_up')); |
|
|
|
upBtn.innerHTML = THUMB_UP; |
|
|
|
upBtn.addEventListener('click', (e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
const root = wrapper.closest('.csk-window'); |
|
|
|
if (root) { |
|
|
|
root.dispatchEvent(new CustomEvent('csk:feedback', { |
|
|
|
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' } |
|
|
|
})); |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
const downBtn = document.createElement('button'); |
|
|
|
downBtn.type = 'button'; |
|
|
|
downBtn.className = 'csk-feedback-btn'; |
|
|
|
downBtn.setAttribute('title', t('feedback_down')); |
|
|
|
downBtn.setAttribute('aria-label', t('feedback_down')); |
|
|
|
downBtn.innerHTML = THUMB_DOWN; |
|
|
|
downBtn.addEventListener('click', (e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
/** |
|
|
|
* 创建 AI 消息操作条(t-chat-action,投射到 t-chat-item 的 actionbar 插槽) |
|
|
|
* 复制/重试/点赞/点踩均由 TDesign 组件承担;handleAction 回调负责分发 SDK 自定义事件 |
|
|
|
*/ |
|
|
|
function createChatAction(wrapper: HTMLElement, text: string, msgId?: string, feedback?: 'up' | 'down'): HTMLElement { |
|
|
|
const actionEl = document.createElement('t-chat-action'); |
|
|
|
actionEl.setAttribute('slot', 'actionbar'); |
|
|
|
const el = actionEl as unknown as { |
|
|
|
actionBar: string[]; |
|
|
|
handleAction: (action: string, data: { event?: Event; active?: boolean }) => void; |
|
|
|
}; |
|
|
|
el.actionBar = ['replay', 'copy', 'good', 'bad']; |
|
|
|
// copyText/comment 为 String 简单类型,须用 setAttribute(property 赋值在 omi 下静默失效)
|
|
|
|
actionEl.setAttribute('copyText', text); |
|
|
|
actionEl.setAttribute('comment', feedback === 'up' ? 'good' : feedback === 'down' ? 'bad' : ''); |
|
|
|
el.handleAction = (action, data) => { |
|
|
|
const root = wrapper.closest('.csk-window'); |
|
|
|
if (root) { |
|
|
|
if (!root) return; |
|
|
|
if (action === 'replay') { |
|
|
|
root.dispatchEvent(new CustomEvent('csk:retry', { detail: { msgId: msgId || '' } })); |
|
|
|
} else if (action === 'good' || action === 'bad') { |
|
|
|
root.dispatchEvent(new CustomEvent('csk:feedback', { |
|
|
|
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' } |
|
|
|
detail: { msgId: msgId || '', value: action === 'good' ? 'up' : 'down', active: !!data?.active } |
|
|
|
})); |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
bar.appendChild(copyBtn); |
|
|
|
bar.appendChild(retryBtn); |
|
|
|
bar.appendChild(upBtn); |
|
|
|
bar.appendChild(downBtn); |
|
|
|
return bar; |
|
|
|
// copy 由 t-chat-action 内部处理(用 copyText 调 navigator.clipboard),无需分发
|
|
|
|
}; |
|
|
|
return actionEl; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 更新反馈按钮的视觉状态(由 chat.ts 调用) |
|
|
|
* @param wrapper 消息 wrapper 元素 |
|
|
|
* @param value 当前反馈值:'up' | 'down' | undefined |
|
|
|
* 回滚 t-chat-action 的反馈图标状态(重建组件为 comment='')。 |
|
|
|
* t-chat-action 的 comment 仅在首次挂载时生效,运行时无法外部更新, |
|
|
|
* 点踩弹原因面板被用户关闭时,通过重建组件来恢复未选中状态。 |
|
|
|
*/ |
|
|
|
export function updateFeedbackUI(wrapper: HTMLElement, value: 'up' | 'down' | undefined): void { |
|
|
|
const btns = wrapper.querySelectorAll('.csk-feedback-btn'); |
|
|
|
if (btns.length < 2) return; |
|
|
|
btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down')); |
|
|
|
if (value === 'up' && btns[0]) btns[0].classList.add('csk-feedback-btn--active'); |
|
|
|
if (value === 'down' && btns[1]) btns[1].classList.add('csk-feedback-btn--active', 'down'); |
|
|
|
} |
|
|
|
|
|
|
|
/** 复制文本到剪贴板,带"已复制"反馈 */ |
|
|
|
async function copyToClipboard(text: string, btn: HTMLElement): Promise<void> { |
|
|
|
const original = btn.innerHTML; |
|
|
|
try { |
|
|
|
if (navigator.clipboard && window.isSecureContext) { |
|
|
|
await navigator.clipboard.writeText(text); |
|
|
|
} else { |
|
|
|
// 降级方案:execCommand
|
|
|
|
const ta = document.createElement('textarea'); |
|
|
|
ta.value = text; |
|
|
|
ta.style.position = 'fixed'; |
|
|
|
ta.style.opacity = '0'; |
|
|
|
document.body.appendChild(ta); |
|
|
|
ta.select(); |
|
|
|
document.execCommand('copy'); |
|
|
|
document.body.removeChild(ta); |
|
|
|
} |
|
|
|
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`; |
|
|
|
btn.classList.add('csk-action-btn--done'); |
|
|
|
setTimeout(() => { |
|
|
|
btn.innerHTML = original; |
|
|
|
btn.classList.remove('csk-action-btn--done'); |
|
|
|
}, 1500); |
|
|
|
} catch { |
|
|
|
// 复制失败静默处理
|
|
|
|
} |
|
|
|
export function resetChatActionFeedback(wrapper: HTMLElement): void { |
|
|
|
const msg = wrapper.querySelector('t-chat-item'); |
|
|
|
const actionEl = wrapper.querySelector('t-chat-action'); |
|
|
|
if (!msg || !actionEl) return; |
|
|
|
const text = actionEl.getAttribute('copyText') || ''; |
|
|
|
const msgId = wrapper.dataset.cskMsgId; |
|
|
|
actionEl.remove(); |
|
|
|
msg.appendChild(createChatAction(wrapper, text, msgId, undefined)); |
|
|
|
} |
|
|
|
|
|
|
|
/** 为气泡内所有代码块添加复制按钮 */ |
|
|
|
function enhanceCodeBlocks(bubble: HTMLElement): void { |
|
|
|
const blocks = bubble.querySelectorAll('.csk-md-code-block'); |
|
|
|
blocks.forEach((pre) => { |
|
|
|
if (pre.querySelector('.csk-md-code-copy')) return; |
|
|
|
const codeEl = pre.querySelector('code'); |
|
|
|
const btn = document.createElement('button'); |
|
|
|
btn.type = 'button'; |
|
|
|
btn.className = 'csk-md-code-copy'; |
|
|
|
btn.setAttribute('title', t('copy')); |
|
|
|
btn.setAttribute('aria-label', t('copy')); |
|
|
|
btn.innerHTML = COPY_ICON; |
|
|
|
btn.addEventListener('click', async (e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
await copyToClipboard(codeEl?.textContent || '', btn); |
|
|
|
}); |
|
|
|
(pre as HTMLElement).appendChild(btn); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */ |
|
|
|
export function appendStreamingCaret(bubble: HTMLElement): void { |
|
|
|
if (bubble.querySelector('.csk-caret')) return; |
|
|
|
const caret = document.createElement('span'); |
|
|
|
caret.className = 'csk-caret'; |
|
|
|
caret.setAttribute('aria-hidden', 'true'); |
|
|
|
bubble.appendChild(caret); |
|
|
|
} |
|
|
|
|
|
|
|
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */ |
|
|
|
/** 标记 AI 气泡流式结束:切换 status 为 complete,并同步复制内容为最终 markdown 原文 */ |
|
|
|
export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void { |
|
|
|
wrapper.classList.remove('csk-msg--streaming'); |
|
|
|
const caret = bubble.querySelector('.csk-caret'); |
|
|
|
if (caret) caret.remove(); |
|
|
|
enhanceCodeBlocks(bubble); |
|
|
|
bubble.setAttribute('status', 'complete'); |
|
|
|
// 流式期间 copyText 为空,结束后同步为最终原文(t-chat-action 的 copyText 支持运行时更新)
|
|
|
|
const contentArr = (bubble as unknown as { content?: Array<{ data?: string }> }).content; |
|
|
|
const text = (contentArr?.map(c => c.data || '').join('') || ''); |
|
|
|
const actionEl = wrapper.querySelector('t-chat-action'); |
|
|
|
if (actionEl) actionEl.setAttribute('copyText', text); |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== P1: RAG 引用来源渲染 ====================
|
|
|
|
|
|
|
|
/** 渲染 RAG 引用来源卡片 */ |
|
|
|
/** |
|
|
|
* 渲染 RAG 引用来源卡片(自定义折叠) |
|
|
|
* 说明:尝试用 <t-collapse> 替换,但 tdesign-web-components 的 collapse/collapse-panel |
|
|
|
* 依赖 omi 的 provide/inject 机制,在纯 DOM(createElement + appendChild)下无法建立 |
|
|
|
* 注入链路(报 `this.injection.getCollapseValue is not a function`),故保留轻量自定义折叠。 |
|
|
|
*/ |
|
|
|
export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void { |
|
|
|
// 移除已有的来源卡片
|
|
|
|
const existing = wrapper.querySelector('.csk-sources'); |
|
|
|
@ -1149,10 +1020,10 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void |
|
|
|
sourcesEl.appendChild(header); |
|
|
|
sourcesEl.appendChild(body); |
|
|
|
|
|
|
|
// 插入到元信息行之后(来源卡片紧接在 meta 行下方)
|
|
|
|
const metaEl = wrapper.querySelector('.csk-msg__meta'); |
|
|
|
if (metaEl && metaEl.parentNode) { |
|
|
|
metaEl.parentNode.insertBefore(sourcesEl, metaEl.nextSibling); |
|
|
|
// 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方
|
|
|
|
const msgEl = wrapper.querySelector('t-chat-item'); |
|
|
|
if (msgEl && msgEl.parentNode) { |
|
|
|
msgEl.parentNode.insertBefore(sourcesEl, msgEl.nextSibling); |
|
|
|
} else { |
|
|
|
wrapper.appendChild(sourcesEl); |
|
|
|
} |
|
|
|
@ -1330,24 +1201,21 @@ export function scrollToBottom(container: HTMLElement): void { |
|
|
|
|
|
|
|
// ==================== 离线提示横幅 ====================
|
|
|
|
|
|
|
|
let offlineBannerEl: HTMLElement | null = null; |
|
|
|
|
|
|
|
/** 在消息区顶部展示离线提示横幅 */ |
|
|
|
/** 在消息区顶部展示离线提示横幅(t-alert,theme=warning) */ |
|
|
|
export function showOfflineBanner(messagesContainer: HTMLElement): void { |
|
|
|
if (document.getElementById('csk-offline-banner')) return; |
|
|
|
const banner = document.createElement('div'); |
|
|
|
const banner = document.createElement('t-alert'); |
|
|
|
banner.id = 'csk-offline-banner'; |
|
|
|
banner.className = 'csk-offline-banner'; |
|
|
|
banner.textContent = t('error_offline'); |
|
|
|
// message 为复杂类型(Object/Function 等),用 property;theme 为 String 简单类型,用 setAttribute
|
|
|
|
(banner as unknown as { message: string }).message = t('error_offline'); |
|
|
|
banner.setAttribute('theme', 'warning'); |
|
|
|
messagesContainer.insertBefore(banner, messagesContainer.firstChild); |
|
|
|
offlineBannerEl = banner; |
|
|
|
} |
|
|
|
|
|
|
|
/** 隐藏离线提示横幅 */ |
|
|
|
export function hideOfflineBanner(): void { |
|
|
|
const el = document.getElementById('csk-offline-banner'); |
|
|
|
if (el) el.remove(); |
|
|
|
offlineBannerEl = null; |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 建议问题(suggest-message-list) ====================
|
|
|
|
@ -1375,25 +1243,20 @@ export function renderSuggestions( |
|
|
|
list.className = 'csk-suggestions__list'; |
|
|
|
|
|
|
|
for (const text of suggestions) { |
|
|
|
const chip = document.createElement('button'); |
|
|
|
chip.type = 'button'; |
|
|
|
chip.className = 'csk-suggestion-item'; |
|
|
|
chip.textContent = text; |
|
|
|
chip.addEventListener('click', (e) => { |
|
|
|
// on* 回调(onClick)必须经 omi render 创建;tag 的 onClick 首参为原生事件,文本经 children 传入
|
|
|
|
createEventful('t-tag', { |
|
|
|
theme: 'primary', |
|
|
|
variant: 'light-outline', |
|
|
|
onClick: (e: Event) => { |
|
|
|
e.stopPropagation(); |
|
|
|
onClick(text); |
|
|
|
}); |
|
|
|
list.appendChild(chip); |
|
|
|
}, |
|
|
|
}, list, text); |
|
|
|
} |
|
|
|
|
|
|
|
section.appendChild(list); |
|
|
|
// 插入到操作栏和来源卡片之间(在 content 内末尾)
|
|
|
|
const content = wrapper.querySelector('.csk-msg__content'); |
|
|
|
if (content) { |
|
|
|
content.appendChild(section); |
|
|
|
} else { |
|
|
|
// 直接追加到 wrapper 末尾(wrapper 内即 t-chat-item,无 .csk-msg__content 中间层)
|
|
|
|
wrapper.appendChild(section); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
@ -1404,7 +1267,7 @@ export function removeSuggestions(wrapper: HTMLElement): void { |
|
|
|
if (existing) existing.remove(); |
|
|
|
} |
|
|
|
|
|
|
|
// ==================== 反馈原因选择面板 ====================
|
|
|
|
// ==================== 确认弹窗 + 反馈原因选择 ====================
|
|
|
|
|
|
|
|
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */ |
|
|
|
const REASON_OPTIONS: { key: string; labelKey: string }[] = [ |
|
|
|
@ -1415,13 +1278,50 @@ const REASON_OPTIONS: { key: string; labelKey: string }[] = [ |
|
|
|
]; |
|
|
|
|
|
|
|
/** |
|
|
|
* 在消息气泡下方展示点踩原因选择面板 |
|
|
|
* 用户选择原因后回调 onConfirm(reason, comment),取消时回调 undefined 参数 |
|
|
|
* 命令式确认弹窗(DialogPlugin.confirm),替代原生 confirm() |
|
|
|
* @returns 确认返回 true,取消/关闭返回 false |
|
|
|
*/ |
|
|
|
export function confirmWithDialog(message: string): Promise<boolean> { |
|
|
|
return new Promise((resolve) => { |
|
|
|
let settled = false; |
|
|
|
const finish = (val: boolean): void => { |
|
|
|
if (settled) return; |
|
|
|
settled = true; |
|
|
|
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 / 遮罩点击 / 关闭按钮兜底
|
|
|
|
finish(false); |
|
|
|
dlg.destroy(); |
|
|
|
}, |
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 在消息气泡下方展示点踩原因选择面板(内联,按钮用 t-button) |
|
|
|
* 说明:尝试用 <t-dialog> 改为模态弹窗,但 dialog 的 visible/confirmBtn/cancelBtn 属性 |
|
|
|
* 未在 omi propTypes 声明,纯 DOM property 赋值不生效(渲染为 display:none),且复杂 |
|
|
|
* body 无法通过 DialogPlugin 注入,故保留内联面板形态,仅将按钮升级为 t-button。 |
|
|
|
*/ |
|
|
|
export function showFeedbackReasonPanel( |
|
|
|
wrapper: HTMLElement, |
|
|
|
msgId: string, |
|
|
|
_msgId: string, |
|
|
|
onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void, |
|
|
|
onCancel?: () => void, |
|
|
|
): void { |
|
|
|
// 移除已存在的原因面板
|
|
|
|
const existing = wrapper.querySelector('.csk-feedback-reason'); |
|
|
|
@ -1436,14 +1336,16 @@ export function showFeedbackReasonPanel( |
|
|
|
title.textContent = t('feedback_reason_title'); |
|
|
|
panel.appendChild(title); |
|
|
|
|
|
|
|
// 原因选项按钮
|
|
|
|
// 原因选项按钮(t-button,点击即确认)
|
|
|
|
const optionsRow = document.createElement('div'); |
|
|
|
optionsRow.className = 'csk-feedback-reason__options'; |
|
|
|
REASON_OPTIONS.forEach((opt) => { |
|
|
|
const btn = document.createElement('button'); |
|
|
|
btn.type = 'button'; |
|
|
|
btn.className = 'csk-feedback-reason__btn'; |
|
|
|
const btn = document.createElement('t-button'); |
|
|
|
// t-button 文本通过 <slot> 渲染(light DOM children),须用 textContent 而非 content 属性
|
|
|
|
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'); |
|
|
|
@ -1463,10 +1365,10 @@ export function showFeedbackReasonPanel( |
|
|
|
commentInput.maxLength = 200; |
|
|
|
commentRow.appendChild(commentInput); |
|
|
|
|
|
|
|
const submitBtn = document.createElement('button'); |
|
|
|
submitBtn.type = 'button'; |
|
|
|
submitBtn.className = 'csk-feedback-reason__submit'; |
|
|
|
const submitBtn = document.createElement('t-button'); |
|
|
|
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; |
|
|
|
@ -1476,7 +1378,7 @@ export function showFeedbackReasonPanel( |
|
|
|
commentRow.appendChild(submitBtn); |
|
|
|
panel.appendChild(commentRow); |
|
|
|
|
|
|
|
// 关闭按钮
|
|
|
|
// 关闭按钮(右上角,轻量原生 button 承载 X 图标)
|
|
|
|
const closeBtn = document.createElement('button'); |
|
|
|
closeBtn.type = 'button'; |
|
|
|
closeBtn.className = 'csk-feedback-reason__close'; |
|
|
|
@ -1485,6 +1387,7 @@ export function showFeedbackReasonPanel( |
|
|
|
closeBtn.addEventListener('click', (e) => { |
|
|
|
e.stopPropagation(); |
|
|
|
panel.remove(); |
|
|
|
if (onCancel) onCancel(); |
|
|
|
}); |
|
|
|
panel.appendChild(closeBtn); |
|
|
|
|
|
|
|
|