/** * Soul创业派对 - 超级个体发动态(AI 辅助 + 朋友圈式编辑) */ const app = getApp() const { uploadByApi } = require('../../utils/miniprogramUpload') const TEXT_EXTS = ['.txt', '.md', '.json', '.csv', '.log', '.yaml', '.yml'] const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] const MAX_TEXT_READ_BYTES = 1024 * 1024 const MAX_REFERENCE_SEND = 28000 function newAttachId() { return `f_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` } Page({ data: { /** 临时隐藏「AI 写文章」卡片;需上线时再设为 true */ showAiWriteSection: false, statusBarHeight: 44, auditMode: false, title: '', content: '', bodyImages: [], saving: false, meNickname: '', aiPrompt: '', attachedFiles: [], generating: false, editArticleId: '', }, /** 遮挡层拦截滚动穿透 */ preventMove() {}, syncAuditMode() { const audit = !!app.globalData.auditMode this.setData({ auditMode: audit }) return audit }, onLoad(options) { const editId = options && options.id != null ? String(options.id).trim() : '' if (editId) { this._pendingEditId = editId this.setData({ editArticleId: editId }) } this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44, meNickname: String(app.globalData.userInfo?.nickname || '我').trim() || '我', }) this.syncAuditMode() try { app.getAuditMode && app.getAuditMode().catch(() => {}) } catch (_) {} this.ensureSuperIdentity() }, onShow() { this.syncAuditMode() try { app.getAuditMode && app.getAuditMode().catch(() => {}) } catch (_) {} }, async ensureSuperIdentity() { const userId = app.globalData.userInfo?.id if (!app.globalData.isLoggedIn || !userId) { wx.showToast({ title: '请先登录', icon: 'none' }) setTimeout(() => wx.navigateBack(), 400) return } try { const res = await app.request({ url: `/api/miniprogram/my/super-stats?userId=${encodeURIComponent(userId)}`, silent: true, }) if (!(res?.success && res.data?.isSuperIndividual === true)) { wx.showToast({ title: '仅超级个体可发动态', icon: 'none' }) setTimeout(() => wx.navigateBack(), 400) return } const pid = this._pendingEditId ? String(this._pendingEditId).trim() : '' if (pid) { this._pendingEditId = '' await this.loadArticleForEdit(pid) } } catch (_) { wx.showToast({ title: '身份校验失败', icon: 'none' }) setTimeout(() => wx.navigateBack(), 400) } }, async loadArticleForEdit(id) { const userId = app.globalData.userInfo?.id if (!userId) return wx.showLoading({ title: '加载中…', mask: true }) try { const res = await app.request({ url: `/api/miniprogram/super/articles/${encodeURIComponent(String(id))}?viewerUserId=${encodeURIComponent(String(userId))}`, silent: true, }) const article = res?.data if (!article) throw new Error('加载失败') if (String(article.userId) !== String(userId)) throw new Error('无权编辑') const st = String(article.auditStatus || '').toLowerCase() if (st !== 'pending' && st !== 'rejected') throw new Error('仅待审核或已驳回的动态可编辑') const imgs = Array.isArray(article.images) ? article.images.map((u) => String(u || '').trim()).filter(Boolean) : [] const bodyImages = imgs.map((url) => ({ id: newAttachId(), url, uploading: false })) this.setData({ editArticleId: String(id), title: '', content: String(article.content || ''), bodyImages, }) } catch (e) { wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' }) setTimeout(() => wx.navigateBack(), 500) } finally { try { wx.hideLoading() } catch (_) {} } }, onAiPromptInput(e) { this.setData({ aiPrompt: e.detail.value || '' }) }, onContentInput(e) { this.setData({ content: e.detail.value || '' }) }, insertMentionSelf() { if (this.data.auditMode) return const nick = this.data.meNickname || '我' this.setData({ content: `${this.data.content}@${nick} ` }) }, insertHashLink() { if (this.data.auditMode) return this.setData({ content: `${this.data.content}#链接(https://)` }) }, /** 编辑区配图(发布后展示在正文前),最多 9 张 */ chooseArticleImages() { this.syncAuditMode() if (this.data.auditMode || app.globalData.auditMode) { wx.showToast({ title: '审核模式下不可用', icon: 'none' }) return } const max = 9 const cur = this.data.bodyImages.length if (cur >= max) { wx.showToast({ title: '最多 9 张配图', icon: 'none' }) return } const pickCount = max - cur const mapFromChooseMedia = (res) => { const files = res.tempFiles || [] return files.map((f, i) => ({ path: f.tempFilePath, name: `配图_${Date.now()}_${i}.jpg`, size: f.size || 0, })) } const mapFromChooseImage = (res) => { const paths = res.tempFilePaths || [] const t = Date.now() return paths.map((path, i) => ({ path, name: `配图_${t}_${i}.jpg`, size: 0, })) } const runUpload = async (tempFiles) => { for (const f of tempFiles) { if (this.data.bodyImages.length >= max) break await this._uploadOneArticleBodyImage(f) } } if (typeof wx.chooseMedia === 'function') { wx.chooseMedia({ count: pickCount, mediaType: ['image'], sourceType: ['album', 'camera'], sizeType: ['compressed'], success: (res) => { void runUpload(mapFromChooseMedia(res)) }, fail: (err) => { const em = String((err && err.errMsg) || '') if (/cancel|取消/i.test(em)) return wx.showToast({ title: '选择图片失败', icon: 'none' }) }, }) return } if (typeof wx.chooseImage === 'function') { wx.chooseImage({ count: pickCount, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (res) => { void runUpload(mapFromChooseImage(res)) }, fail: (err) => { const em = String((err && err.errMsg) || '') if (/cancel|取消/i.test(em)) return wx.showToast({ title: '选择图片失败', icon: 'none' }) }, }) return } wx.showToast({ title: '当前环境不支持选图片', icon: 'none' }) }, async _uploadOneArticleBodyImage(f) { const filePath = f.path const name = String(f.name || '配图').trim() || '配图' const size = f.size || 0 if (!filePath) return if (size > 29 * 1024 * 1024) { wx.showToast({ title: '单张请勿超过 30MB', icon: 'none' }) return } const id = newAttachId() this.setData({ bodyImages: this.data.bodyImages.concat([{ id, url: '', uploading: true }]), }) try { const up = await uploadByApi({ filePath, folder: 'super-article-images' }) const url = this._pickUploadUrl(up) if (!url) throw new Error('no url') const next = this.data.bodyImages.map((x) => x.id === id ? Object.assign({}, x, { url, uploading: false }) : x, ) this.setData({ bodyImages: next }) } catch (_) { this.setData({ bodyImages: this.data.bodyImages.filter((x) => x.id !== id), }) wx.showToast({ title: `${name} 上传失败`, icon: 'none' }) } }, removeArticleImage(e) { if (this.data.auditMode) return const id = e.currentTarget.dataset.id if (!id) return this.setData({ bodyImages: this.data.bodyImages.filter((x) => x.id !== id), }) }, /** 参考素材:仅从相册/相机选图片并上传(AI 侧按图理解) */ chooseReferenceImages() { this.syncAuditMode() if (this.data.auditMode || app.globalData.auditMode) { wx.showToast({ title: '审核模式下不可用', icon: 'none' }) return } this._pickReferenceImagesFromAlbum() }, _pickReferenceImagesFromAlbum() { const mapFromChooseMedia = (res) => { const files = res.tempFiles || [] return files.map((f, i) => ({ path: f.tempFilePath, name: `参考图_${Date.now()}_${i}.jpg`, size: f.size || 0, })) } const mapFromChooseImage = (res) => { const paths = res.tempFilePaths || [] const t = Date.now() return paths.map((path, i) => ({ path, name: `参考图_${t}_${i}.jpg`, size: 0, })) } if (typeof wx.chooseMedia === 'function') { wx.chooseMedia({ count: 9, mediaType: ['image'], sourceType: ['album', 'camera'], sizeType: ['compressed'], success: (res) => this.handlePickedFiles(mapFromChooseMedia(res)), fail: (err) => { const em = String((err && err.errMsg) || '') if (/cancel|取消/i.test(em)) return wx.showToast({ title: '选择图片失败', icon: 'none' }) }, }) return } if (typeof wx.chooseImage === 'function') { wx.chooseImage({ count: 9, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (res) => this.handlePickedFiles(mapFromChooseImage(res)), fail: (err) => { const em = String((err && err.errMsg) || '') if (/cancel|取消/i.test(em)) return wx.showToast({ title: '选择图片失败', icon: 'none' }) }, }) return } wx.showToast({ title: '当前环境不支持选图片', icon: 'none' }) }, _pickUploadUrl(up) { if (!up || typeof up !== 'object') return '' return String(up.url || up.fullUrl || up.data?.url || up.data?.fullUrl || '').trim() }, _patchAttach(id, patch) { const next = this.data.attachedFiles.map((x) => x.id === id ? Object.assign({}, x, patch) : x, ) this.setData({ attachedFiles: next }) }, _removeAttachById(id) { this.setData({ attachedFiles: this.data.attachedFiles.filter((x) => x.id !== id), }) }, async handlePickedFiles(tempFiles) { if (!tempFiles.length) return for (const f of tempFiles) { const name = String(f.name || '未命名').trim() || '未命名' const filePath = f.path const size = f.size || 0 const dot = name.lastIndexOf('.') const ext = dot >= 0 ? name.slice(dot).toLowerCase() : '' const id = newAttachId() if (size > 29 * 1024 * 1024) { wx.showToast({ title: `${name} 超过30MB上限`, icon: 'none' }) continue } if (IMAGE_EXTS.includes(ext)) { this.setData({ attachedFiles: this.data.attachedFiles.concat([ { id, name, kind: 'image', url: '', uploading: true }, ]), }) try { const up = await uploadByApi({ filePath, folder: 'article-images' }) const url = this._pickUploadUrl(up) if (!url) throw new Error('no url') this._patchAttach(id, { url, uploading: false }) } catch (_) { this._removeAttachById(id) wx.showToast({ title: `${name} 上传失败`, icon: 'none' }) } continue } if (TEXT_EXTS.includes(ext) && size <= MAX_TEXT_READ_BYTES && size >= 0) { try { const fs = wx.getFileSystemManager() const textContent = fs.readFileSync(filePath, 'utf8') this.setData({ attachedFiles: this.data.attachedFiles.concat([ { id, name, kind: 'text', textContent, uploading: false }, ]), }) } catch (_) { await this._uploadBinaryAttach(id, name, filePath) } continue } await this._uploadBinaryAttach(id, name, filePath) } }, async _uploadBinaryAttach(id, name, filePath) { this.setData({ attachedFiles: this.data.attachedFiles.concat([ { id, name, kind: 'binary', url: '', uploading: true }, ]), }) try { const up = await uploadByApi({ filePath, folder: 'book-attachments' }) const url = this._pickUploadUrl(up) if (!url) throw new Error('no url') this._patchAttach(id, { url, uploading: false }) } catch (_) { this._removeAttachById(id) wx.showToast({ title: `${name} 上传失败`, icon: 'none' }) } }, removeAttachedFile(e) { if (this.data.auditMode) return const id = e.currentTarget.dataset.id if (!id) return this._removeAttachById(id) }, async generateArticle() { if (this.data.auditMode || app.globalData.auditMode) return if (this.data.generating) return const userId = app.globalData.userInfo?.id if (!userId) { wx.showToast({ title: '请先登录', icon: 'none' }) return } if (this.data.attachedFiles.some((x) => x.uploading)) { wx.showToast({ title: '请等待图片上传完成', icon: 'none' }) return } const prompt = String(this.data.aiPrompt || '').trim() const parts = [] for (const f of this.data.attachedFiles) { if (f.kind === 'text' && f.textContent) { parts.push(`--- ${f.name} ---\n${f.textContent}`) } } let referenceText = parts.join('\n\n').trim() if (referenceText.length > MAX_REFERENCE_SEND) { referenceText = referenceText.slice(0, MAX_REFERENCE_SEND) + '\n\n(本地参考文末已截断)' } const materialUrls = this.data.attachedFiles .filter((x) => x.kind === 'binary' && x.url) .map((x) => x.url) const imageUrls = this.data.attachedFiles .filter((x) => x.kind === 'image' && x.url) .map((x) => x.url) if (!prompt && !referenceText && materialUrls.length === 0 && imageUrls.length === 0) { wx.showToast({ title: '请填写提示词或添加参考图', icon: 'none' }) return } this.setData({ generating: true }) try { const res = await app.request({ url: '/api/miniprogram/super/articles/generate', method: 'POST', timeout: 120000, data: { userId, description: prompt, referenceText, materialUrls, imageUrls, }, }) if (!res?.success) throw new Error(res?.error || '生成失败') const title = String(res.data?.title || '').trim() const content = String(res.data?.content || '').trim() let merged = content if (title && merged) merged = `${title}\n\n${merged}` else if (title) merged = title if (!merged.trim()) throw new Error('生成结果不完整') this.setData({ title: '', content: merged.trim() }) wx.showToast({ title: '已填入正文', icon: 'success' }) } catch (e) { wx.showToast({ title: (e && e.message) || '生成失败', icon: 'none', duration: 2800, }) } finally { this.setData({ generating: false }) } }, async submitArticle() { if (this.data.auditMode || app.globalData.auditMode) return if (this.data.saving) return const userId = app.globalData.userInfo?.id if (!userId) { wx.showToast({ title: '请先登录', icon: 'none' }) return } const content = String(this.data.content || '').trim() if (this.data.bodyImages.some((x) => x.uploading)) { wx.showToast({ title: '请等待配图上传完成', icon: 'none' }) return } const images = this.data.bodyImages .map((x) => String(x.url || '').trim()) .filter(Boolean) if (!content && images.length === 0) { wx.showToast({ title: '请填写文字或添加配图', icon: 'none' }) return } const editId = String(this.data.editArticleId || '').trim() this.setData({ saving: true }) wx.showLoading({ title: editId ? '提交中…' : '发布中…', mask: true }) try { let res if (editId) { res = await app.request({ url: `/api/miniprogram/super/articles/${encodeURIComponent(editId)}`, method: 'PUT', data: { userId, title: '', content, images }, }) } else { res = await app.request({ url: '/api/miniprogram/super/articles', method: 'POST', data: { userId, title: '', content, images }, }) } if (!res?.success) throw new Error(res?.error || '发布失败') if (editId) { wx.showToast({ title: '已提交审核', icon: 'success' }) setTimeout(() => wx.navigateBack(), 450) } else { this.setData({ title: '', content: '', bodyImages: [] }) wx.showToast({ title: '已提交审核', icon: 'success' }) } } catch (e) { wx.showToast({ title: e.message || '发布失败', icon: 'none' }) } finally { try { wx.hideLoading() } catch (_) {} this.setData({ saving: false }) } }, goBack() { wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/my/my' }) }) }, })