/** * Modal 弹窗组件 */ import { t } from '../lib/i18n.js' // 转义 HTML 属性值,防止双引号等字符破坏 HTML 结构 function escapeAttr(str) { if (!str) return '' return String(str) .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>') } /** * 自定义确认弹窗,替代原生 confirm() * Tauri WebView 不支持原生 confirm/alert,必须用自定义弹窗 * * 入参 message 支持两种: * 1) string —— 旧用法(向后兼容) * 2) { message, impact?, ...options } —— 结构化致命操作确认: * - message: 主问题("删除 Agent 'main'?") * - impact: 影响列表 string[],渲染成 ul(让小白看清楚删了会丢什么) * - title/confirmText/cancelText/variant: 同 options 同名字段 * * @param {string|object} message 确认消息或结构化对象 * @param {object} [options] 旧版 options 字段(仍兼容) * @returns {Promise} 用户选择确认返回 true,取消返回 false */ export function showConfirm(message, options = {}) { // 结构化入参:把对象字段合并到 options,message 取其 .message 字段 let actualMessage = message let impactList = null if (message && typeof message === 'object') { actualMessage = message.message ?? '' impactList = Array.isArray(message.impact) ? message.impact.filter(Boolean) : null // 对象字段优先于 options 同名字段 options = { title: message.title ?? options.title, confirmText: message.confirmText ?? options.confirmText, cancelText: message.cancelText ?? options.cancelText, variant: message.variant ?? options.variant, } } const title = options.title || t('common.confirmAction') const confirmText = options.confirmText || t('common.confirm') const cancelText = options.cancelText || t('common.cancel') const variant = options.variant === 'primary' ? 'btn-primary' : 'btn-danger' const impactHtml = impactList && impactList.length ? `` : '' return new Promise((resolve) => { const overlay = document.createElement('div') overlay.className = 'modal-overlay' overlay.innerHTML = ` ` document.body.appendChild(overlay) const close = (result) => { overlay.remove() resolve(result) } overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false) }) overlay.querySelector('[data-action="cancel"]').onclick = () => close(false) overlay.querySelector('[data-action="confirm"]').onclick = () => close(true) overlay.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); close(true) } else if (e.key === 'Escape') close(false) }) // 聚焦取消按钮(致命操作默认不要默认聚焦确认) overlay.querySelector('[data-action="cancel"]').focus() }) } export function showModal({ title, fields, onConfirm }) { const overlay = document.createElement('div') overlay.className = 'modal-overlay' const fieldHtml = fields.map(f => { if (f.type === 'checkbox') { return `
${f.hint ? `
${f.hint}
` : ''}
` } if (f.type === 'select') { return `
${f.hint ? `
${f.hint}
` : ''}
` } return `
${f.hint ? `
${f.hint}
` : ''}
` }).join('') overlay.innerHTML = ` ` document.body.appendChild(overlay) // 点击遮罩关闭 overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() }) overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() overlay.querySelector('[data-action="confirm"]').onclick = () => { const result = {} overlay.querySelectorAll('[data-name]').forEach(el => { if (el.type === 'checkbox') { result[el.dataset.name] = el.checked } else { result[el.dataset.name] = el.value } }) // 先调用回调,再移除 overlay,避免嵌套对话框时序问题 const callback = onConfirm setTimeout(() => overlay.remove(), 0) callback(result) } // 键盘事件:Enter 确认,Escape 关闭 const handleKey = (e) => { if (e.key === 'Enter') { e.preventDefault() overlay.querySelector('[data-action="confirm"]')?.click() } else if (e.key === 'Escape') { overlay.remove() } } overlay.addEventListener('keydown', handleKey) // 自动聚焦第一个输入框 const firstInput = overlay.querySelector('input, select') if (firstInput) firstInput.focus() } /** * 通用内容弹窗 — 支持自定义 HTML 和按钮 * @param {{ title, content, buttons, width }} opts * buttons: [{ label, className, id }] * @returns {HTMLElement} overlay 元素(带 .close() 方法) */ export function showContentModal({ title, content, buttons = [], width = 480 }) { const overlay = document.createElement('div') overlay.className = 'modal-overlay' const btnsHtml = buttons.map(b => `` ).join('') overlay.innerHTML = ` ` document.body.appendChild(overlay) overlay.close = () => overlay.remove() overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() }) overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.remove() }) // 自动聚焦第一个输入框或按钮 const firstInput = overlay.querySelector('input, textarea, select') if (firstInput) firstInput.focus() return overlay } /** * 升级进度弹窗 — 带进度条和实时日志 * @returns {{ appendLog, setProgress, setDone, setError, destroy }} */ export function showUpgradeModal(title) { const overlay = document.createElement('div') overlay.className = 'modal-overlay' overlay.innerHTML = ` ` document.body.appendChild(overlay) const fill = overlay.querySelector('.upgrade-progress-fill') const text = overlay.querySelector('.upgrade-progress-text') const logBox = overlay.querySelector('.upgrade-log-box') const closeBtn = overlay.querySelector('[data-action="close"]') const _logLines = [] let _onClose = null let _finished = false let _taskBar = null let _progressLabels = null // 重新打开弹窗(从任务状态栏点击时) function reopenModal() { if (_taskBar) { _taskBar.remove(); _taskBar = null } document.body.appendChild(overlay) } // 关闭弹窗:未完成时显示任务状态栏 function closeModal() { overlay.remove() if (!_finished) { showTaskBar() } else { if (_taskBar) { _taskBar.remove(); _taskBar = null } _onClose?.() } } // 全局任务状态栏:关闭弹窗后显示在页面顶部 function showTaskBar() { if (_taskBar) return _taskBar = document.createElement('div') _taskBar.className = 'upgrade-task-bar' _taskBar.innerHTML = ` ${text.textContent} ` _taskBar.querySelector('.upgrade-task-bar-open').onclick = reopenModal _taskBar.querySelector('.upgrade-task-bar-dismiss').onclick = () => { _taskBar.remove(); _taskBar = null } document.body.appendChild(_taskBar) } function updateTaskBar(statusText) { if (_taskBar) { const span = _taskBar.querySelector('.upgrade-task-bar-text') if (span) span.textContent = statusText } } closeBtn.onclick = closeModal overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal() }) return { appendLog(line) { _logLines.push(line) const div = document.createElement('div') div.textContent = line logBox.appendChild(div) logBox.scrollTop = logBox.scrollHeight }, appendHtmlLog(line) { _logLines.push(line) const div = document.createElement('div') div.innerHTML = line logBox.appendChild(div) logBox.scrollTop = logBox.scrollHeight }, getLogText() { return _logLines.join('\n') }, setProgressLabels(labels) { _progressLabels = labels }, setProgress(pct) { fill.style.width = pct + '%' const labels = _progressLabels || {} let statusText if (pct >= 100) statusText = labels.done || t('common.completed') else if (pct >= 75) statusText = labels.installing || t('common.installingProgress') else if (pct >= 30) statusText = labels.downloading || t('common.downloadingDependencies') else statusText = labels.preparing || t('common.preparing') text.textContent = statusText updateTaskBar(statusText) }, setDone(msg) { _finished = true text.textContent = msg || t('common.upgradeCompleted') fill.style.width = '100%' fill.classList.add('done') if (_taskBar) { _taskBar.remove(); _taskBar = null } closeBtn.focus() }, setError(msg) { _finished = true text.textContent = msg || t('common.upgradeFailed') fill.classList.add('error') if (_taskBar) { const span = _taskBar.querySelector('.upgrade-task-bar-text') if (span) { span.textContent = msg || t('common.upgradeFailed'); span.style.color = 'var(--error)' } } closeBtn.focus() }, onClose(fn) { _onClose = fn }, destroy() { overlay.remove(); if (_taskBar) { _taskBar.remove(); _taskBar = null } _onClose?.() }, } }