功能:通过人工智能辅助和用户界面改进,增强超级文章编辑器的功能

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

此次更新旨在简化文章创建流程,并增强超级文章编辑器内的用户互动。
This commit is contained in:
乘风
2026-05-11 16:36:54 +08:00
parent 01f9e216da
commit 4a105204cc
9 changed files with 978 additions and 448 deletions

View File

@@ -4,19 +4,15 @@
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 帮你写」卡片:参考图 + 提示词 + 生成 */
/** 「AI 帮你写」入口(弹窗内填写提示词并生成 */
showAiWriteSection: true,
aiAssistVisible: false,
statusBarHeight: 44,
auditMode: false,
title: '',
@@ -25,9 +21,10 @@ Page({
saving: false,
meNickname: '',
aiPrompt: '',
attachedFiles: [],
generating: false,
editArticleId: '',
/** 正文光标,用于 @自己 插入 */
contentCursor: 0,
},
/** 遮挡层拦截滚动穿透 */
@@ -35,10 +32,33 @@ Page({
syncAuditMode() {
const audit = !!app.globalData.auditMode
this.setData({ auditMode: audit })
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) {
@@ -114,6 +134,7 @@ Page({
title: '',
content: String(article.content || ''),
bodyImages,
contentCursor: String(article.content || '').length,
})
} catch (e) {
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
@@ -130,21 +151,45 @@ Page({
},
onContentInput(e) {
this.setData({ content: e.detail.value || '' })
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.setData({ content: `${this.data.content}@${nick} ` })
this.insertAtContentCursor(`@${nick} `)
},
insertHashLink() {
if (this.data.auditMode) return
this.setData({ content: `${this.data.content}#链接(https://)` })
},
/** 编辑区配图(发布后展示在正文前),最多 9 张 */
/** 动态配图(发动态展示 + AI 生成多模态共用),最多 9 张 */
chooseArticleImages() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
@@ -255,160 +300,11 @@ Page({
})
},
/** 参考素材:仅从相册/相机选图片并上传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
@@ -418,34 +314,20 @@ Page({
return
}
if (this.data.attachedFiles.some((x) => x.uploading)) {
wx.showToast({ title: '请等待图上传完成', icon: 'none' })
if (this.data.bodyImages.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 referenceText = ''
const materialUrls = []
const imageUrls = this.data.bodyImages
.map((x) => String(x.url || '').trim())
.filter(Boolean)
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' })
if (!prompt && imageUrls.length === 0) {
wx.showToast({ title: '请填写提示词或添加配图', icon: 'none' })
return
}
@@ -470,7 +352,13 @@ Page({
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() })
const mergedStr = merged.trim()
this.setData({
title: '',
content: mergedStr,
contentCursor: mergedStr.length,
aiAssistVisible: false,
})
wx.showToast({ title: '已填入正文', icon: 'success' })
} catch (e) {
wx.showToast({
@@ -522,13 +410,13 @@ Page({
})
}
if (!res?.success) throw new Error(res?.error || '发布失败')
if (editId) {
wx.showToast({ title: '已提交审核', icon: 'success' })
setTimeout(() => wx.navigateBack(), 450)
} else {
if (!editId) {
this.setData({ title: '', content: '', bodyImages: [] })
wx.showToast({ title: '已提交审核', icon: 'success' })
}
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 {

View File

@@ -8,72 +8,89 @@
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- AI参考图片 + 提示词开关showAiWriteSection -->
<view wx:if="{{showAiWriteSection}}" class="card">
<text class="section-title">AI 帮你写</text>
<text class="label">参考图片(可选)</text>
<view class="file-actions">
<view class="add-file-btn" bindtap="chooseReferenceImages"> 添加图片</view>
</view>
<view wx:for="{{attachedFiles}}" wx:key="id" class="file-row">
<view class="file-main">
<text class="file-name">{{item.name}}</text>
<text wx:if="{{item.uploading}}" class="file-meta">上传中…</text>
<text wx:elif="{{item.kind === 'text'}}" class="file-meta ok">文本已载入</text>
<text wx:elif="{{item.kind === 'image'}}" class="file-meta ok">图片</text>
<text wx:else class="file-meta ok">附件</text>
<!-- 主编曲面:配图 → 正文(快捷插入含 AI点开底部弹层 -->
<view class="composer-sheet">
<view class="sheet-section">
<view class="section-head">
<text class="section-title-text">配图</text>
<text class="section-badge">{{bodyImages.length}}/9</text>
</view>
<text class="file-remove" data-id="{{item.id}}" bindtap="removeAttachedFile">移除</text>
</view>
<text class="hint">从相册或相机添加参考图,可选(单次最多 9 张),上传后与提示词一并交给 AI 分析。单张建议不超 30MB。</text>
<text class="label">提示词</text>
<view class="textarea-wrap">
<textarea class="textarea prompt-textarea" maxlength="2000" placeholder="描述主题、语气、要点…(可与参考图组合)" value="{{aiPrompt}}" bindinput="onAiPromptInput" disabled="{{auditMode}}"></textarea>
<text class="section-desc">可选 · 弹窗内「AI 帮你写」会与下方配图共用 · 发布后展示在正文下方</text>
<view class="moment-photo-grid">
<view wx:for="{{bodyImages}}" wx:key="id" class="moment-photo-cell">
<image wx:if="{{item.url}}" class="moment-photo-img" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="moment-photo-mask">上传中…</view>
<text class="moment-photo-remove" data-id="{{item.id}}" catchtap="removeArticleImage">×</text>
</view>
<view wx:if="{{bodyImages.length < 9}}" class="moment-photo-cell moment-photo-add" bindtap="chooseArticleImages">
<text class="moment-photo-add-plus">+</text>
</view>
</view>
<text class="hint-micro">单张建议不超 30MB</text>
</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">{{generating ? '生成中…' : '生成'}}</view>
<view class="section-divider"></view>
<view class="sheet-section sheet-section--body">
<view class="section-head">
<text class="section-title-text">正文</text>
<text class="section-meta">最多 5000 字</text>
</view>
<view class="inline-toolbar">
<text class="toolbar-label">快捷插入</text>
<view class="tool-btn" hover-class="tool-btn-hover" catchtap="insertMentionSelf">@自己</view>
<view wx:if="{{showAiWriteSection && !auditMode}}" class="tool-btn" hover-class="tool-btn-hover" catchtap="openAiAssist">✨ AI 帮你写</view>
</view>
<view class="textarea-wrap moment-textarea-wrap">
<textarea
class="textarea moment-textarea"
maxlength="5000"
placeholder="写点什么…支持 Markdown、多段落"
model:value="{{content}}"
bindinput="onContentInput"
bindblur="onContentBlur"
cursor="{{contentCursor}}"
disabled="{{auditMode}}"
auto-height
show-confirm-bar="{{false}}"
></textarea>
</view>
</view>
</view>
<!-- 朋友圈式:先文字,再九宫格配图(+ 在最后) -->
<view class="card moment-card">
<view class="toolbar">
<view class="tool-btn" bindtap="insertMentionSelf">@自己</view>
<view class="tool-btn" bindtap="insertHashLink">#链接</view>
</view>
<view class="textarea-wrap moment-textarea-wrap">
<textarea
class="textarea moment-textarea"
maxlength="5000"
placeholder="这一刻的想法…"
value="{{content}}"
bindinput="onContentInput"
disabled="{{auditMode}}"
auto-height
show-confirm-bar="{{false}}"
></textarea>
</view>
<view class="moment-photo-grid">
<view wx:for="{{bodyImages}}" wx:key="id" class="moment-photo-cell">
<image wx:if="{{item.url}}" class="moment-photo-img" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="moment-photo-mask">上传中…</view>
<text class="moment-photo-remove" data-id="{{item.id}}" catchtap="removeArticleImage">×</text>
</view>
<view wx:if="{{bodyImages.length < 9}}" class="moment-photo-cell moment-photo-add" bindtap="chooseArticleImages">
<text class="moment-photo-add-plus">+</text>
</view>
</view>
<text class="hint moment-hint">配图 {{bodyImages.length}}/9 · 展示在文字下方 · 单张建议不超 30MB · 可直接输入 @昵称 或 #链接(https://xxx)</text>
</view>
<!-- 底部发布(避让微信胶囊) -->
<view wx:if="{{!auditMode}}" class="bottom-publish-bar">
<view class="bottom-publish-btn {{saving ? 'bottom-publish-btn-disabled' : ''}}" bindtap="submitArticle">
<text>{{saving ? (editArticleId ? '提交中…' : '发布中…') : (editArticleId ? '提交审核' : '发布')}}</text>
</view>
</view>
<!-- AI 底部弹层 -->
<view wx:if="{{aiAssistVisible}}" class="ai-modal-root" catchtouchmove="preventMove">
<view class="ai-modal-mask" catchtap="closeAiAssist"></view>
<view class="ai-modal-panel" catchtap="noopModalTap">
<view class="ai-modal-head">
<text class="ai-modal-title">AI 帮你写</text>
<text class="ai-modal-close" catchtap="closeAiAssist">关闭</text>
</view>
<text class="ai-modal-desc">提示词 + 当前配图一并交给模型;生成后写入正文,可关闭弹窗继续编辑</text>
<text class="label-ai">提示词</text>
<view class="textarea-wrap textarea-wrap-ai">
<textarea
class="textarea prompt-textarea-inner"
maxlength="2000"
placeholder="例如:根据配图写一篇派对复盘…"
value="{{aiPrompt}}"
bindinput="onAiPromptInput"
disabled="{{auditMode}}"
show-confirm-bar="{{false}}"
></textarea>
</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">
{{generating ? '生成中…' : '生成并填入正文'}}
</view>
</view>
</view>
<view wx:if="{{auditMode}}" class="audit-mode-mask" catchtouchmove="preventMove">
<text class="audit-mode-text">开发中...</text>
</view>

View File

@@ -1,248 +1,790 @@
.page {
min-height: 100vh;
background: #0b1220;
background: linear-gradient(180deg, #080f18 0%, #0b1220 32%, #0b1220 100%);
color: #fff;
box-sizing: border-box;
padding-bottom: calc(168rpx + env(safe-area-inset-bottom));
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
.nav-bar {
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 24rpx; background: rgba(5, 11, 20, 0.9);
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 24rpx;
background: rgba(5, 11, 20, 0.92);
border-bottom: 1rpx solid rgba(94, 234, 212, 0.08);
}
.nav-back { width: 64rpx; flex-shrink: 0; display: flex; align-items: center; }
.nav-placeholder { width: 64rpx; flex-shrink: 0; }
.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; }
.nav-back {
width: 64rpx;
flex-shrink: 0;
display: flex;
align-items: center;
}
.nav-placeholder {
width: 64rpx;
flex-shrink: 0;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 700;
}
/* ========== 主编曲面 ========== */
.composer-sheet {
margin: 16rpx 24rpx 24rpx;
border-radius: 28rpx;
overflow: hidden;
background: rgba(15, 23, 42, 0.92);
border: 1rpx solid rgba(148, 163, 184, 0.22);
box-shadow: 0 24rpx 48rpx rgba(0, 0, 0, 0.35);
}
.sheet-section {
padding: 28rpx 28rpx 26rpx;
}
.sheet-section--body {
padding-top: 24rpx;
padding-bottom: 28rpx;
}
.sheet-section--ai {
padding-top: 24rpx;
padding-bottom: 30rpx;
background: linear-gradient(
180deg,
rgba(8, 47, 73, 0.22) 0%,
rgba(15, 23, 42, 0.05) 100%
);
}
.section-divider {
height: 1rpx;
margin: 0 28rpx;
background: rgba(148, 163, 184, 0.14);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 12rpx;
}
.section-title-text {
font-size: 30rpx;
font-weight: 700;
color: #f1f5f9;
letter-spacing: 1rpx;
}
.section-badge {
font-size: 22rpx;
font-weight: 600;
color: #22d3ee;
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: rgba(34, 211, 238, 0.12);
border: 1rpx solid rgba(34, 211, 238, 0.28);
}
.section-meta {
font-size: 22rpx;
color: #94a3b8;
}
.section-tag-ai {
font-size: 22rpx;
color: #a5f3fc;
padding: 4rpx 14rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(94, 234, 212, 0.35);
background: rgba(34, 211, 238, 0.08);
}
.section-desc {
display: block;
font-size: 22rpx;
color: #94a3b8;
line-height: 1.55;
margin-bottom: 16rpx;
}
.hint-micro {
display: block;
margin-top: 14rpx;
font-size: 20rpx;
color: #64748b;
}
.inline-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 14rpx;
}
.toolbar-label {
font-size: 22rpx;
color: #64748b;
margin-right: 4rpx;
}
.tool-btn {
padding: 12rpx 22rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 600;
color: #67e8f9;
border: 1rpx solid rgba(34, 211, 238, 0.4);
background: rgba(8, 47, 73, 0.5);
}
.tool-btn-hover {
opacity: 0.82;
}
.label-ai {
display: block;
font-size: 24rpx;
color: #cbd5e1;
margin-bottom: 10rpx;
margin-top: 4rpx;
}
.textarea-wrap,
.textarea-wrap-ai {
background: rgba(2, 6, 23, 0.78);
border-radius: 18rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(148, 163, 184, 0.12);
}
.textarea-wrap-ai {
margin-bottom: 16rpx;
}
.moment-textarea-wrap {
padding: 20rpx 22rpx;
}
.textarea {
width: 100%;
min-height: 320rpx;
color: #fff;
font-size: 30rpx;
line-height: 1.65;
background: transparent;
}
.moment-textarea {
min-height: 280rpx;
}
.prompt-textarea-inner {
min-height: 160rpx;
width: 100%;
font-size: 28rpx;
line-height: 1.55;
color: #f1f5f9;
background: transparent;
}
.generate-btn {
margin-top: 8rpx;
height: 88rpx;
border-radius: 18rpx;
border: 1rpx solid rgba(34, 211, 238, 0.5);
color: #ecfeff;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(8, 47, 73, 0.85), rgba(15, 118, 110, 0.35));
}
.generate-btn-loading {
opacity: 0.65;
pointer-events: none;
}
.bottom-publish-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(11, 18, 32, 0.96);
border-top: 1rpx solid rgba(94, 234, 212, 0.12);
}
.bottom-publish-btn {
height: 92rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.bottom-publish-btn-disabled {
opacity: 0.65;
pointer-events: none;
}
.card {
margin: 24rpx; padding: 28rpx; border-radius: 24rpx;
background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2);
}
.section-title {
display: block; font-size: 26rpx; font-weight: 700; color: #e2e8f0;
margin-bottom: 20rpx; letter-spacing: 2rpx;
}
.label { display: block; font-size: 24rpx; color: #cbd5e1; margin-bottom: 12rpx; }
.input-wrap, .textarea-wrap { background: rgba(2, 6, 23, 0.75); border-radius: 16rpx; padding: 18rpx 20rpx; }
.input { width: 100%; color: #fff; font-size: 28rpx; background: transparent; }
.file-actions { margin-bottom: 12rpx; }
.add-file-btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 14rpx 28rpx; border-radius: 16rpx; font-size: 26rpx; font-weight: 600;
color: #22d3ee; border: 1rpx solid rgba(34, 211, 238, 0.45);
background: rgba(8, 47, 73, 0.45);
}
.file-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16rpx; padding: 16rpx 18rpx; margin-bottom: 12rpx;
border-radius: 14rpx; background: rgba(2, 6, 23, 0.55); border: 1rpx solid rgba(148, 163, 184, 0.12);
}
.file-main { flex: 1; min-width: 0; }
.file-name { display: block; font-size: 26rpx; color: #f1f5f9; word-break: break-all; line-height: 1.45; }
.file-meta { display: block; font-size: 22rpx; color: #94a3b8; margin-top: 6rpx; }
.file-meta.ok { color: #5eead4; }
.file-remove {
flex-shrink: 0; font-size: 24rpx; color: #f97316; padding: 8rpx 12rpx;
}
.body-img-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 12rpx;
}
.body-img-cell {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 14rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.18);
}
.body-img-thumb {
width: 100%;
height: 100%;
display: block;
}
.body-img-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
}
.body-img-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.prompt-textarea { min-height: 200rpx; }
.generate-btn {
margin-top: 20rpx; height: 88rpx; border-radius: 16rpx;
border: 1rpx solid rgba(34, 211, 238, 0.55); color: #a5f3fc; font-size: 30rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
background: rgba(8, 47, 73, 0.45);
}
.generate-btn-loading { opacity: 0.7; pointer-events: none; }
.toolbar { margin-top: 12rpx; margin-bottom: 12rpx; display: flex; gap: 12rpx; }
.tool-btn {
padding: 10rpx 18rpx; border-radius: 999rpx; font-size: 22rpx;
color: #67e8f9; border: 1rpx solid rgba(34, 211, 238, 0.35); background: rgba(8, 47, 73, 0.45);
}
.textarea {
width: 100%; min-height: 380rpx; color: #fff; font-size: 28rpx; line-height: 1.65;
background: transparent;
}
.hint { display: block; margin-top: 10rpx; color: #94a3b8; font-size: 22rpx; }
/* 发动态 · 配图宫格(参考朋友圈) */
.moment-card .toolbar {
margin-top: 0;
}
.moment-textarea-wrap {
margin-top: 8rpx;
padding: 20rpx 22rpx;
}
.moment-textarea {
min-height: 240rpx;
width: 100%;
font-size: 30rpx;
line-height: 1.65;
}
/* 配图宫格 */
.moment-photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 20rpx;
}
.moment-photo-cell {
position: relative;
width: calc((100% - 32rpx) / 3);
padding-bottom: calc((100% - 32rpx) / 3);
height: 0;
border-radius: 12rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
box-sizing: border-box;
}
.moment-photo-img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.moment-photo-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
z-index: 1;
}
.moment-photo-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.moment-photo-add {
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.moment-photo-add-plus {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 64rpx;
font-weight: 300;
color: rgba(148, 163, 184, 0.85);
line-height: 1;
}
.moment-hint {
margin-top: 16rpx;
}
/* 审核模式:盖住整页(含自定义导航),高于 .nav-bar 的 z-index: 10 */
.audit-mode-mask {
position: fixed;
.moment-photo-cell {
position: relative;
width: calc((100% - 32rpx) / 3);
padding-bottom: calc((100% - 32rpx) / 3);
height: 0;
border-radius: 14rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
box-sizing: border-box;
}
.moment-photo-img {
position: absolute;
left: 0;
right: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.moment-photo-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
z-index: 1;
}
.moment-photo-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.moment-photo-add {
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.moment-photo-add-plus {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 64rpx;
font-weight: 300;
color: rgba(148, 163, 184, 0.85);
line-height: 1;
}
/* AI 底部弹层 */
.ai-modal-root {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 600;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.ai-modal-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.58);
}
.ai-modal-panel {
position: relative;
z-index: 1;
border-radius: 28rpx 28rpx 0 0;
padding: 28rpx 28rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
max-height: 78vh;
overflow-y: auto;
background: rgba(15, 23, 42, 0.98);
border: 1rpx solid rgba(148, 163, 184, 0.22);
border-bottom: none;
box-shadow: 0 -12rpx 48rpx rgba(0, 0, 0, 0.45);
}
.ai-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.ai-modal-title {
font-size: 32rpx;
font-weight: 700;
color: #f1f5f9;
}
.ai-modal-close {
font-size: 26rpx;
color: #67e8f9;
padding: 12rpx 16rpx;
}
.ai-modal-desc {
display: block;
font-size: 22rpx;
color: #94a3b8;
line-height: 1.55;
margin-bottom: 16rpx;
}
.audit-mode-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(11, 18, 32, 0.94);
}
.audit-mode-text {
font-size: 36rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
letter-spacing: 4rpx;
}

View File

@@ -78,11 +78,15 @@ function mapRows(rows) {
return (rows || []).map((r) => {
const st = String(r.auditStatus || 'approved').toLowerCase()
const thumb = buildThumbSlots(r.images)
const contentTrim = String(r.content || '').trim()
const titleTrim = String(r.title || '').trim()
return Object.assign({}, r, {
auditStatus: st === 'pending' || st === 'rejected' ? st : 'approved',
auditLabel: auditLabel(st),
timeText: relativeTime(r.createdAt),
preview: String(r.preview || '').trim(),
contentTrim,
titleTrim,
rejectReason: String(r.rejectReason || '').trim(),
showThumbRow: thumb.showThumbRow,
thumbSlots: thumb.thumbSlots,

View File

@@ -31,9 +31,10 @@
<text class="audit-tag audit-{{item.auditStatus}}">{{item.auditLabel}}</text>
<text class="feed-time">{{item.timeText}}</text>
</view>
<text class="feed-title" wx:if="{{item.title}}">{{item.title}}</text>
<text class="feed-title feed-title-fallback" wx:elif="{{item.preview}}">{{item.preview}}</text>
<text class="feed-title feed-title-fallback" wx:else>动态</text>
<view class="feed-title" wx:if="{{item.contentTrim}}">{{item.contentTrim}}</view>
<view class="feed-title feed-title-fallback" wx:elif="{{item.titleTrim}}">{{item.titleTrim}}</view>
<view class="feed-title feed-title-fallback" wx:elif="{{item.preview}}">{{item.preview}}</view>
<view class="feed-title feed-title-fallback" wx:else>动态</view>
<view class="feed-thumb-row" wx:if="{{item.showThumbRow}}" catchtap="noop">
<view class="thumb-cell" wx:for="{{item.thumbSlots}}" wx:for-item="th" wx:key="slotIdx">

View File

@@ -146,14 +146,19 @@
}
.feed-title {
display: block;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
width: 100%;
box-sizing: border-box;
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.45;
}
.feed-title-fallback {

View File

@@ -96,6 +96,8 @@ Page({
hasMore: true,
loading: true,
loadingMore: false,
/** 超级个体:右下角发动态入口 */
showSuperPublishFab: false,
},
async onLoad() {
@@ -108,6 +110,7 @@ Page({
return
}
this.reloadFeed()
this.refreshSuperPublishFab()
},
async onShow() {
@@ -123,6 +126,29 @@ Page({
if (tabBar && tabBar.loadFeatureConfig) tabBar.loadFeatureConfig()
if (tabBar && tabBar.updateSelected) tabBar.updateSelected()
}
this.refreshSuperPublishFab()
},
/** 仅超级个体展示发动态悬浮按钮 */
async refreshSuperPublishFab() {
const userId = app.globalData.userInfo?.id
let show = false
if (userId && app.globalData.isLoggedIn) {
try {
const res = await app.request({
url: `/api/miniprogram/my/super-stats?userId=${encodeURIComponent(userId)}`,
silent: true,
})
show = !!(res?.success && res.data?.isSuperIndividual === true)
} catch (_) {
show = false
}
}
this.setData({ showSuperPublishFab: show })
},
goPublishMoment() {
wx.navigateTo({ url: '/pages/super-article-editor/super-article-editor' })
},
onReachBottom() {
@@ -139,6 +165,7 @@ Page({
}
try {
await this.reloadFeed()
await this.refreshSuperPublishFab()
} finally {
wx.stopPullDownRefresh()
}

View File

@@ -58,4 +58,15 @@
<text wx:elif="{{!hasMore}}">已经到底啦</text>
</view>
</view>
<!-- 超级个体:右下角发动态(层级高于自定义 TabBar -->
<view
wx:if="{{showSuperPublishFab}}"
class="moments-fab-publish"
hover-class="moments-fab-publish-hover"
bindtap="goPublishMoment"
aria-label="发动态"
>
<text class="moments-fab-plus">+</text>
</view>
</view>

View File

@@ -166,3 +166,38 @@
font-size: 24rpx;
color: #64748b;
}
/* 超级个体发动态:右下角圆钮(与读页分享 FAB、TabBar 品牌青一致) */
.moments-fab-publish {
position: fixed;
right: 32rpx;
bottom: calc(156rpx + env(safe-area-inset-bottom));
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #00ced1 0%, #20b2aa 100%);
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.42);
z-index: 1002;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.moments-fab-publish-hover {
opacity: 0.92;
}
.moments-fab-publish:active {
transform: scale(0.94);
}
/* 原生「+」,避免 icon 组件 SVG 在 FAB 内偶发不渲染 */
.moments-fab-plus {
font-size: 56rpx;
font-weight: 300;
color: #ffffff;
line-height: 1;
margin-top: -4rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
}