/** * Session Save & Share 共享组件 * * 聊天页面:Upload & Share 按钮 → 有前端录制 blob 时先上传再复制链接,无 blob 直接复制链接 + 5s toast。 * 首页:读取 localStorage 展示 Recent Sessions 列表。 * * 使用方式(聊天页面): * const ui = new SaveShareUI({ * containerId: 'save-share-container', * appType: 'omni_duplex', * collectComment: true, // 可选:分享前弹评语对话框 * }); * ui.setSessionId('omni_xxx'); * // 前端录制完成后: * ui.setRecordingBlob(blob, 'webm'); * * 协议(移动端、桌面端均共用): * POST /api/sessions/{id}/upload-recording (FormData: file) * POST /api/sessions/{id}/comment (JSON: {comment: string}) * GET /api/sessions/{id}/comment → {comment: string} * localStorage["minicpmo45_recent_sessions"]: [{id, appType, savedAt}, ...] (最多 20 条) */ const RECENT_SESSIONS_KEY = 'minicpmo45_recent_sessions'; const MAX_RECENT = 20; function _ssT(key, fallback) { return window.I18n?.t?.[key] ?? fallback; } /* ─────────── 静态 helpers(与移动端共用同一协议) ─────────── */ async function uploadRecording(sessionId, blob, ext) { const form = new FormData(); form.append('file', blob, `recording.${ext || 'webm'}`); const resp = await fetch(`/api/sessions/${sessionId}/upload-recording`, { method: 'POST', body: form, }); if (!resp.ok) { const detail = await resp.text().catch(() => ''); throw new Error(`Upload failed: ${resp.status} ${detail}`); } return resp.json().catch(() => ({})); } async function saveComment(sessionId, comment) { const text = (comment || '').trim(); const resp = await fetch(`/api/sessions/${sessionId}/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: text }), }); if (!resp.ok) { const detail = await resp.text().catch(() => ''); throw new Error(`Comment save failed: ${resp.status} ${detail}`); } return resp.json().catch(() => ({})); } async function fetchComment(sessionId) { const resp = await fetch(`/api/sessions/${sessionId}/comment`); if (!resp.ok) return ''; const j = await resp.json().catch(() => ({ comment: '' })); return j.comment || ''; } function buildShareUrl(sessionId) { return `${window.location.origin}/s/${sessionId}`; } function addToRecent(sessionId, appType) { if (!sessionId) return; let list = []; try { list = JSON.parse(localStorage.getItem(RECENT_SESSIONS_KEY) || '[]'); } catch { list = []; } if (!Array.isArray(list)) list = []; const idx = list.findIndex(s => s && s.id === sessionId); if (idx !== -1) list.splice(idx, 1); list.unshift({ id: sessionId, appType: appType || 'unknown', savedAt: new Date().toISOString() }); if (list.length > MAX_RECENT) list.length = MAX_RECENT; try { localStorage.setItem(RECENT_SESSIONS_KEY, JSON.stringify(list)); } catch { /* quota */ } } function getRecentSessions() { try { return JSON.parse(localStorage.getItem(RECENT_SESSIONS_KEY) || '[]'); } catch { return []; } } function clearRecentSessions() { localStorage.removeItem(RECENT_SESSIONS_KEY); } /* Explicitly publish to window so ES modules (e.g. omni-app.js) can see * us. Top-level `class X` in a classic script lives in the global * lexical environment, which modules *should* also see — but a couple * of older mobile WebViews have been observed to miss it. Belt-and- * braces: also expose on window. */ class SaveShareUI { /** * @param {Object} opts * @param {string} opts.containerId - 挂载容器的 DOM id * @param {string} [opts.appType] - 应用类型标识 * @param {boolean} [opts.collectComment=false] - 分享前弹评语对话框 */ constructor(opts) { this.appType = opts.appType || 'unknown'; this.collectComment = !!opts.collectComment; this._sessionId = null; this._recordingBlob = null; this._recordingExt = null; this._uploading = false; this._container = document.getElementById(opts.containerId); if (!this._container) return; this._render(); } setSessionId(sessionId) { this._sessionId = sessionId; this._updateBtn(); } /** * 设置前端录制的 Blob,Upload & Share 时会先上传此文件 * @param {Blob} blob - 录制的音频/视频 blob * @param {string} ext - 文件扩展名 ('webm', 'wav', 'mp4') */ setRecordingBlob(blob, ext) { this._recordingBlob = blob; this._recordingExt = ext || 'webm'; this._updateBtn(); } _updateBtn() { const btn = this._container?.querySelector('.ss-btn'); if (btn) { btn.disabled = !this._sessionId || this._uploading; btn.textContent = this._uploading ? _ssT('uploading', 'Uploading…') : _ssT('uploadAndShare', 'Upload & Share'); } } _render() { this._container.innerHTML = ` `; this._container.querySelector('.ss-btn').addEventListener('click', () => this._onClick()); } async _onClick() { if (!this._sessionId || this._uploading) return; if (!this.collectComment) { return this._doShare(null); } const comment = await this._promptComment(); if (comment === null) return; // 用户取消 return this._doShare(comment); } /** 弹一个评语对话框,resolve(string) 或 resolve(null) 取消。 */ _promptComment() { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'ss-modal-overlay'; overlay.innerHTML = ` `; const close = (val) => { overlay.removeEventListener('keydown', keyHandler); overlay.remove(); resolve(val); }; const keyHandler = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(null); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); close(overlay.querySelector('.ss-modal-input').value); } }; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); }); overlay.querySelector('.ss-modal-cancel').addEventListener('click', () => close(null)); overlay.querySelector('.ss-modal-ok').addEventListener('click', () => { close(overlay.querySelector('.ss-modal-input').value); }); overlay.addEventListener('keydown', keyHandler); document.body.appendChild(overlay); // Focus & select for snappy typing const ta = overlay.querySelector('.ss-modal-input'); setTimeout(() => ta.focus(), 0); }); } async _doShare(comment) { const sid = this._sessionId; const url = buildShareUrl(sid); // 1. Save comment first (best-effort; failure shouldn't block link). if (comment !== null && comment !== undefined && String(comment).trim()) { try { await saveComment(sid, comment); } catch (e) { console.warn('[SaveShare] saveComment failed:', e); this._showToast(`${_ssT('commentSaveFailed', 'Comment save failed: ')}${e.message}`, true); } } // 2. Upload front-end recording blob if any. if (this._recordingBlob && this._recordingBlob.size > 0) { this._uploading = true; this._updateBtn(); this._showToast(_ssT('uploading', 'Uploading…')); try { await uploadRecording(sid, this._recordingBlob, this._recordingExt); } catch (e) { console.error('[SaveShare] upload error:', e); this._showToast(`${_ssT('uploadFailed', 'Upload failed: ')}${e.message}\n${url}`, true); this._uploading = false; this._updateBtn(); addToRecent(sid, this.appType); return; } this._uploading = false; this._updateBtn(); } // 3. Add to recent + copy link. addToRecent(sid, this.appType); navigator.clipboard.writeText(url).then(() => { this._showToast(`${_ssT('copiedToClipboard', 'Copied to clipboard')}\n${url}`); }).catch(() => { this._showToast(`${_ssT('shareLink', 'Share link: ')}${url}`, true); }); } _showToast(text, isManual) { const toast = this._container?.querySelector('.ss-toast'); if (!toast) return; toast.textContent = text; toast.style.display = 'block'; toast.classList.toggle('manual', !!isManual); clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => { toast.style.display = 'none'; }, 5000); } static getRecentSessions() { return getRecentSessions(); } static clearRecentSessions() { clearRecentSessions(); } static buildShareUrl(sid) { return buildShareUrl(sid); } static addToRecent(sid, appType) { addToRecent(sid, appType); } static uploadRecording(sid, blob, ext) { return uploadRecording(sid, blob, ext); } static saveComment(sid, comment) { return saveComment(sid, comment); } static fetchComment(sid) { return fetchComment(sid); } } // Belt-and-braces: also expose on window so ES modules can find us. if (typeof window !== 'undefined') { window.SaveShareUI = SaveShareUI; } /* Inject minimal styles */ (function() { if (document.getElementById('ss-styles')) return; const s = document.createElement('style'); s.id = 'ss-styles'; s.textContent = ` .ss-btn { padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; background: #2d2d2d; color: #fff; border: none; cursor: pointer; transition: opacity 0.15s; } .ss-btn:disabled { opacity: 0.35; cursor: not-allowed; } .ss-btn:not(:disabled):hover { opacity: 0.8; } .ss-toast { position: fixed; bottom: 56px; right: 16px; background: rgba(30,30,30,0.92); color: #fff; padding: 10px 16px; border-radius: 10px; font-size: 13px; line-height: 1.5; white-space: pre-line; max-width: 360px; box-shadow: 0 4px 20px rgba(0,0,0,0.18); animation: ss-fade-in 0.2s ease; z-index: 10000; } .ss-toast.manual { user-select: all; } @keyframes ss-fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .ss-modal-overlay { position: fixed; inset: 0; background: rgba(20,20,20,0.45); display: flex; align-items: center; justify-content: center; z-index: 10001; padding: 20px; animation: ss-fade-in 0.15s ease; } .ss-modal { background: #fff; border-radius: 14px; padding: 20px 22px; width: 100%; max-width: 420px; box-shadow: 0 20px 50px rgba(0,0,0,0.25); display: flex; flex-direction: column; gap: 12px; } .ss-modal-title { font-size: 16px; font-weight: 600; color: #1c1c1c; } .ss-modal-hint { font-size: 12.5px; color: #666; line-height: 1.45; } .ss-modal-input { min-height: 90px; max-height: 220px; resize: vertical; padding: 10px 12px; border: 1px solid #d4d4cf; border-radius: 8px; font-size: 13.5px; line-height: 1.5; font-family: inherit; outline: none; transition: border-color 0.15s; color: #1c1c1c; background: #fff; } .ss-modal-input:focus { border-color: #2d2d2d; } .ss-modal-actions { display: flex; justify-content: flex-end; gap: 8px; } .ss-modal-btn { padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: opacity 0.15s, background 0.15s; } .ss-modal-cancel { background: #f3f3ee; color: #444; border-color: #e5e5e0; } .ss-modal-cancel:hover { background: #e8e8e3; } .ss-modal-ok { background: #2d2d2d; color: #fff; } .ss-modal-ok:hover { opacity: 0.85; } `; document.head.appendChild(s); })();