Files
Mycontent/miniprogram/pages/super-article-editor/super-article-editor.js
乘风 4a105204cc 功能:通过人工智能辅助和用户界面改进,增强超级文章编辑器的功能
- 集成人工智能写作辅助功能,用户可在编辑器中输入提示并直接生成内容。
- 更新了用户界面组件,以提升用户体验,包括新增了一个用于发布动态的浮动操作按钮,以及改进了图片上传的布局。
- 重构了内容处理方式,以支持光标管理和动态内容插入,从而增强了编辑功能。
- 通过更新样式和响应式设计元素,改进了视觉反馈和可访问性。

此次更新旨在简化文章创建流程,并增强超级文章编辑器内的用户互动。
2026-05-11 16:36:54 +08:00

434 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Soul创业派对 - 超级个体发动态AI 辅助 + 朋友圈式编辑)
*/
const app = getApp()
const { uploadByApi } = require('../../utils/miniprogramUpload')
function newAttachId() {
return `f_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
Page({
data: {
/** 「AI 帮你写」入口(弹窗内填写提示词并生成) */
showAiWriteSection: true,
aiAssistVisible: false,
statusBarHeight: 44,
auditMode: false,
title: '',
content: '',
bodyImages: [],
saving: false,
meNickname: '',
aiPrompt: '',
generating: false,
editArticleId: '',
/** 正文光标,用于 @自己 插入 */
contentCursor: 0,
},
/** 遮挡层拦截滚动穿透 */
preventMove() {},
syncAuditMode() {
const audit = !!app.globalData.auditMode
const patch = { auditMode: audit }
if (audit) patch.aiAssistVisible = false
this.setData(patch)
return audit
},
openAiAssist() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
return
}
if (!this.data.showAiWriteSection) return
this.setData({ aiAssistVisible: true })
},
closeAiAssist() {
if (this.data.generating) {
wx.showToast({ title: '生成中,请稍候…', icon: 'none' })
return
}
this.setData({ aiAssistVisible: false })
},
/** 阻止弹层内容区点击冒泡到遮罩(部分机型兼容) */
noopModalTap() {},
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,
contentCursor: String(article.content || '').length,
})
} 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) {
const v = e.detail.value != null ? String(e.detail.value) : ''
let cursor = e.detail.cursor
if (typeof cursor !== 'number' || Number.isNaN(cursor)) cursor = v.length
cursor = Math.max(0, Math.min(cursor, v.length))
this.setData({ contentCursor: cursor })
},
onContentBlur(e) {
const v = e.detail.value != null ? String(e.detail.value) : ''
let cursor = e.detail.cursor
if (typeof cursor !== 'number' || Number.isNaN(cursor)) cursor = v.length
cursor = Math.max(0, Math.min(cursor, v.length))
this.setData({ contentCursor: cursor })
},
insertAtContentCursor(insert) {
if (this.data.auditMode) return false
const raw = this.data.content != null ? String(this.data.content) : ''
const seg = String(insert || '')
if (!seg) return false
let pos = this.data.contentCursor
if (typeof pos !== 'number' || Number.isNaN(pos)) pos = raw.length
pos = Math.max(0, Math.min(pos, raw.length))
const next = raw.slice(0, pos) + seg + raw.slice(pos)
const newCursor = pos + seg.length
this.setData({
content: next,
contentCursor: newCursor,
})
return true
},
insertMentionSelf() {
if (this.data.auditMode) return
const nick = this.data.meNickname || '我'
this.insertAtContentCursor(`@${nick} `)
},
/** 动态配图(发动态展示 + AI 生成多模态共用),最多 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),
})
},
_pickUploadUrl(up) {
if (!up || typeof up !== 'object') return ''
return String(up.url || up.fullUrl || up.data?.url || up.data?.fullUrl || '').trim()
},
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.bodyImages.some((x) => x.uploading)) {
wx.showToast({ title: '请等待配图上传完成', icon: 'none' })
return
}
const prompt = String(this.data.aiPrompt || '').trim()
const referenceText = ''
const materialUrls = []
const imageUrls = this.data.bodyImages
.map((x) => String(x.url || '').trim())
.filter(Boolean)
if (!prompt && 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('生成结果不完整')
const mergedStr = merged.trim()
this.setData({
title: '',
content: mergedStr,
contentCursor: mergedStr.length,
aiAssistVisible: false,
})
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) {
this.setData({ title: '', content: '', bodyImages: [] })
}
wx.showToast({ title: '已提交审核', icon: 'success' })
setTimeout(() => {
wx.redirectTo({ url: '/pages/super-article-mine/super-article-mine' })
}, 450)
} 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' }) })
},
})