- Integrated AI writing capabilities in the super article editor, allowing users to upload multiple reference files and generate drafts using prompts. - Updated the reading page to unify the display of book chapters and super articles, improving user navigation and experience. - Enhanced the "My" page with new UI elements, including gradient icons and improved layout for better visual appeal. - Added backend support for new article management endpoints, ensuring seamless integration with the front end. This update aims to streamline the article creation process and enhance the overall user interface across the application.
546 lines
17 KiB
JavaScript
546 lines
17 KiB
JavaScript
/**
|
||
* 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 帮你写」卡片:参考图 + 提示词 + 生成 */
|
||
showAiWriteSection: true,
|
||
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' }) })
|
||
},
|
||
})
|