- 集成人工智能写作辅助功能,用户可在编辑器中输入提示并直接生成内容。 - 更新了用户界面组件,以提升用户体验,包括新增了一个用于发布动态的浮动操作按钮,以及改进了图片上传的布局。 - 重构了内容处理方式,以支持光标管理和动态内容插入,从而增强了编辑功能。 - 通过更新样式和响应式设计元素,改进了视觉反馈和可访问性。 此次更新旨在简化文章创建流程,并增强超级文章编辑器内的用户互动。
434 lines
13 KiB
JavaScript
434 lines
13 KiB
JavaScript
/**
|
||
* 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' }) })
|
||
},
|
||
})
|