import JSZip from 'jszip' const BASE_URL = 'https://cloud.yuxindazhineng.com' const CACHE_PREFIX = 'wyxd_' // ===== 缓存管理 ===== const cacheKey = (workspaceId, filePath) => `${CACHE_PREFIX}${workspaceId}_${filePath}` export const isWyxdCached = (workspaceId, filePath) => { try { const cached = uni.getStorageSync(cacheKey(workspaceId, filePath)) return !!(cached && cached.slides && cached.slides.length > 0) } catch (_) { return false } } export const getCachedWyxd = (workspaceId, filePath) => { try { const cached = uni.getStorageSync(cacheKey(workspaceId, filePath)) if (cached && cached.slides && cached.slides.length > 0) return cached } catch (_) {} return null } export const setCachedWyxd = (workspaceId, filePath, data) => { try { const toStore = { ...data, cachedAt: Date.now() } uni.setStorageSync(cacheKey(workspaceId, filePath), toStore) } catch (_) {} } export const clearCachedWyxd = (workspaceId, filePath) => { try { uni.removeStorageSync(cacheKey(workspaceId, filePath)) } catch (_) {} } // ===== 下载 ===== const getDownloadUrl = (token, workspaceId, filePath) => { return new Promise((resolve, reject) => { const cleanPath = filePath.startsWith('./') ? filePath.slice(2) : filePath uni.request({ url: `${BASE_URL}/cloud_api/phone/workspace/downloadFile`, method: 'POST', header: { 'Content-Type': 'application/json' }, data: { access_token: token, workspaces_id: workspaceId, file_path: cleanPath }, success: (res) => { if (res.statusCode === 200) { const respond = res.data const arr = respond.success ? (respond.message || respond.data) : respond const fileInfo = Array.isArray(arr) ? arr[0] : arr if (fileInfo && fileInfo.url) { resolve(fileInfo.url) } else { reject(respond?.error || '获取下载地址失败') } } else { reject(`请求失败:${res.statusCode}`) } }, fail: (err) => reject(err) }) }) } export const downloadWyxdAsZip = (token, workspaceId, filePath, onProgress) => { return new Promise(async (resolve, reject) => { try { const downloadUrl = await getDownloadUrl(token, workspaceId, filePath) const downloadTask = uni.downloadFile({ url: downloadUrl, success: (res) => { if (res.statusCode === 200) { resolve(res.tempFilePath) } else { reject(`下载失败:${res.statusCode}`) } }, fail: (err) => reject(err) }) if (onProgress && downloadTask) { downloadTask.onProgressUpdate((res) => { onProgress(res.progress) }) } } catch (err) { reject(err) } }) } // ===== 解压 ===== // 手动 UTF-8 解码,兼容不支持 TextDecoder 的旧版本 WebView const utf8Decode = (uint8arr) => { let result = '' let i = 0 while (i < uint8arr.length) { const b0 = uint8arr[i] if (b0 < 128) { result += String.fromCharCode(b0) i++ } else if (b0 < 224) { result += String.fromCharCode(((b0 & 31) << 6) | (uint8arr[i + 1] & 63)) i += 2 } else if (b0 < 240) { result += String.fromCharCode(((b0 & 15) << 12) | ((uint8arr[i + 1] & 63) << 6) | (uint8arr[i + 2] & 63)) i += 3 } else { result += String.fromCharCode(((b0 & 7) << 18) | ((uint8arr[i + 1] & 63) << 12) | ((uint8arr[i + 2] & 63) << 6) | (uint8arr[i + 3] & 63)) i += 4 } } return result } const readFileAsArrayBuffer = (filePath) => { return new Promise((resolve, reject) => { // App 环境:uni.getFileSystemManager const fs = uni.getFileSystemManager if (typeof fs === 'function') { fs().readFile({ filePath: filePath, encoding: 'base64', success: (res) => { const base64 = res.data || '' const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } resolve(bytes) }, fail: (err) => reject(new Error('无法读取文件:' + (err.errMsg || err.message || ''))) }) return } // H5 环境:fetch blob URL fetch(filePath) .then(res => { if (!res.ok) throw new Error('文件请求失败:' + res.status) return res.arrayBuffer() }) .then(buf => resolve(new Uint8Array(buf))) .catch(err => reject(new Error('H5 读取文件失败:' + (err.message || err)))) }) } export const extractWyxd = async (zipFilePath) => { const data = await readFileAsArrayBuffer(zipFilePath) const zip = await JSZip.loadAsync(data) const result = { header: '', html: '', images: {} } for (const [filename, zipEntry] of Object.entries(zip.files)) { if (zipEntry.dir) continue const pureName = filename.replace(/^.*[/\\]/, '') const content = await zipEntry.async('uint8array') if (pureName === 'header.txt') { result.header = utf8Decode(content) } else if (pureName.endsWith('.html') || pureName.endsWith('.htm')) { result.html = utf8Decode(content) } else if (/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i.test(pureName)) { result.images[filename.replace(/^\.[/\\]?/, '')] = arrayBufferToBase64(content, pureName) } } return result } const arrayBufferToBase64 = (buffer, filename) => { let binary = '' const bytes = new Uint8Array(buffer) for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]) } const ext = (filename || '').split('.').pop().toLowerCase() const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', bmp: 'image/bmp' } const mime = mimeMap[ext] || 'image/png' return `data:${mime};base64,${btoa(binary)}` } // ===== 解析 HTML 为幻灯片 ===== export const parseSlides = (fullHtml, headerHtml, imageMap) => { const bodyMatch = fullHtml.match(/]*>([\s\S]*?)<\/body>/i) const bodyHtml = bodyMatch ? bodyMatch[1] : fullHtml const pageRegex = /]*\/?>/gi const parts = bodyHtml.split(pageRegex) if (parts.length === 0) return [] const pageTags = bodyHtml.match(pageRegex) || [] const slides = [] // 第一段(封面之前可能没有 page 标签) parts.forEach((part, idx) => { const html = part.trim() if (!html) return // 获取对应的 page 标签参数 let pageAttrs = { order: idx, num: 0 } if (idx < pageTags.length) { const tagStr = pageTags[idx] const orderMatch = tagStr.match(/order\s*=\s*["'](\d+)["']/i) const numMatch = tagStr.match(/num\s*=\s*["'](\d+)["']/i) if (orderMatch) pageAttrs.order = parseInt(orderMatch[1]) if (numMatch) pageAttrs.num = parseInt(numMatch[1]) } const slide = { order: pageAttrs.order, num: pageAttrs.num, type: 'content', html: html } if (/class\s*=\s*["'][^"']*\bwyxd-cover\b/i.test(html)) { slide.type = 'cover' } else if (/class\s*=\s*["'][^"']*\bwyxd-toc\b/i.test(html)) { slide.type = 'toc' } slides.push(slide) }) return buildSlideHtml(slides, headerHtml, imageMap, fullHtml) } const getHeadContent = (html) => { const match = String(html).match(/]*>([\s\S]*?)<\/head>/i) return match ? match[1] : '' } const buildSlideHtml = (slides, headerHtml, imageMap, fullHtml) => { const headContent = getHeadContent(fullHtml) const baseStyle = extractBaseStyle(fullHtml) return slides.map((slide) => { let processedHtml = slide.html // 替换图片 src processedHtml = processedHtml.replace(/src\s*=\s*["']([^"']+)["']/gi, (match, src) => { // 尝试多种路径匹配 let base64 = imageMap[src] || imageMap[src.replace(/^.*?images\//i, 'images/')] if (base64) { return `src="${base64}"` } return match }) // 正文页:注入页眉 + 页码 if (slide.type === 'content') { // 页眉 if (headerHtml) { processedHtml = headerHtml + processedHtml } // 页码 if (slide.num > 0) { const pageNumHtml = `
${slide.num}
` processedHtml += pageNumHtml } } return { ...slide, html: `
${processedHtml}
`, headStyle: headContent } }) } const extractBaseStyle = (html) => { const styleMatch = html.match(/]*>([\s\S]*?)<\/style>/i) return styleMatch ? styleMatch[1] : '' }