feat(manuscript): 文稿工作台支持编辑生成结果

生成后的正文可以就地改,不再只能复制出去:
- 正文区加「编辑正文 / 完成编辑」:编辑态换 textarea 编 markdown 源码,
  完成时按需回预览;三端(微信小程序 / H5 / App)行为一致
- 生成标题本来就可在结果区改,编辑正文时一起生效
- 「还原 AI 原文」把正文和标题一起回滚,AI 原文快照在生成完成时落存
- 编辑版本与 AI 原文存本地草稿(uni storage,单槽):退出再进能恢复,
  「清空结果」一并清除草稿
- 有编辑版本时重新生成先弹确认,避免改动被静默冲掉
- 「复制正文」复制的是当前生效版本(编辑过就是编辑后的)

改的是 pages/manuscript/index.vue:data/computed 增加编辑态与 displayMarkdown,
草稿读写走 setStorageSync/getStorageSync,纯前端改动,未动接口与入库
This commit is contained in:
2026-11-02 14:55:24 +08:00
parent c2fba0ffd1
commit e3dcf8093a
+245 -9
View File
@@ -154,8 +154,8 @@
<view class="panel-card result-card">
<view class="result-head">
<view class="panel-title">生成结果</view>
<view class="result-status" :class="loading ? 'result-status--loading' : markdownContent ? 'result-status--done' : ''">
{{ loading ? '生成中' : markdownContent ? '已生成' : '待生成' }}
<view class="result-status" :class="loading ? 'result-status--loading' : hasContent ? 'result-status--done' : ''">
{{ statusText }}
</view>
</view>
@@ -171,20 +171,50 @@
</view>
<view class="field-item">
<view class="field-label">正文内容</view>
<view class="field-label">
<text>正文内容</text>
<text v-if="hasEdited" class="field-label__badge">已编辑</text>
</view>
<view class="result-shell">
<view v-if="loading" class="result-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
<template v-if="editing">
<view class="edit-hint">支持 markdown 语法:# 标题、**加粗**、- 列表;改完点「完成编辑」回预览</view>
<textarea
v-model="editMarkdown"
class="result-editor"
:maxlength="-1"
:auto-height="true"
:adjust-position="true"
:cursor-spacing="24"
placeholder="在这里修改正文"
placeholder-style="color: #c0c4cc;"
/>
</template>
<view v-else-if="loading" class="result-streaming">
{{ displayMarkdown || 'AI 正在生成内容...' }}
</view>
<zero-markdown-view v-else-if="markdownContent" class="result-content" :markdown="markdownContent" />
<zero-markdown-view v-else-if="displayMarkdown" class="result-content" :markdown="displayMarkdown" />
<view v-else class="result-empty">填写创作条件后开始生成</view>
</view>
</view>
<view class="result-actions">
<view
class="result-btn"
:class="{ 'result-btn--disabled': loading || (!editing && !hasContent) }"
@tap="toggleEdit"
>
{{ editing ? '完成编辑' : '编辑正文' }}
</view>
<view class="result-btn" @tap="copyGenerated">复制正文</view>
<view class="result-btn" @tap="copyGeneratedTitle">复制标题</view>
<view class="result-btn" @tap="clearGenerated">清空结果</view>
<view class="result-btn result-btn--ghost" @tap="clearGenerated">清空结果</view>
<view
v-if="editing && canRevert"
class="result-btn result-btn--ghost"
@tap="revertEdit"
>
还原 AI 原文
</view>
</view>
</view>
</scroll-view>
@@ -223,6 +253,13 @@
const DOC_TYPE_VALUES = DOC_TYPE_ITEMS.map((item) => item.value)
const EVENT_TYPE_OPTIONS = ['校园活动', '志愿服务', '会议论坛', '社会实践', '文体活动', '品牌项目']
/**
* 文稿草稿的本地存储 key:存当前这份稿子的正文(编辑过的就存编辑版本)
* 和 AI 原文快照。只存一份 —— 工作台一次只做一份稿子,存多份反而让
* 「进来该恢复哪一份」没有答案。
*/
const DRAFT_KEY = 'manuscript_gen_draft_v1'
const createDefaultForm = () => ({
docType: DOC_TYPE_FREE,
freeContent: '',
@@ -249,6 +286,12 @@
streamStarted: false,
streamFailed: false,
streamResolver: null,
// 正文编辑:AI 原文存快照,编辑版本单独放,随时可还原
editing: false,
editMarkdown: '',
hasEdited: false,
aiMarkdown: '',
aiTitle: '',
}
},
computed: {
@@ -283,9 +326,36 @@
},
markdownContent() {
return this.parseAssistantContent(this.generatedContent).content.trim()
},
/** 当前生效的正文:编辑过就用编辑版本,否则用 AI 生成的 */
displayMarkdown() {
return this.hasEdited ? this.editMarkdown : this.markdownContent
},
hasContent() {
return !!String(this.displayMarkdown || '').trim()
},
/** 有编辑版本才给「还原」,否则按钮没有意义 */
canRevert() {
return this.editing && this.hasEdited && !!String(this.aiMarkdown || '').trim()
},
statusText() {
if (this.loading) {
return '生成中'
}
if (this.editing) {
return '编辑中'
}
return this.hasContent ? (this.hasEdited ? '已编辑' : '已生成') : '待生成'
}
},
onLoad() {
if (this.restoreDraft()) {
this.showToast('已恢复上次编辑的稿件')
}
},
onUnload() {
// 页面被销毁前把编辑版本落本地,避免切走就丢
this.persistDraft()
this.clearStreamResolver()
},
methods: {
@@ -295,6 +365,113 @@
icon: 'none'
})
},
/** 编辑 / 完成编辑:完成时把改动落成编辑版本,并写本地草稿 */
toggleEdit() {
if (this.loading) {
this.showToast('正在生成中,暂不能编辑')
return
}
if (!this.editing) {
if (!this.hasContent) {
this.showToast('还没有可编辑的正文')
return
}
this.editMarkdown = this.displayMarkdown
this.editing = true
return
}
const next = String(this.editMarkdown || '').trim()
if (!next) {
this.showToast('正文不能为空')
return
}
this.editMarkdown = next
// 和 AI 原文一致就不算编辑过,状态标签也不必变成「已编辑」
this.hasEdited = next !== this.markdownContent
this.editing = false
this.persistDraft()
this.showToast('编辑已保存')
},
/**
* 还原成 AI 原文:正文和标题一起回滚。
* AI 原文还要写回 generatedContent —— markdownContent 是「原文」的唯一来源,
* 只改 editMarkdown 的话,hasEdited 一变 false,正文会直接变空。
*/
revertEdit() {
if (!String(this.aiMarkdown || '').trim()) {
this.showToast('没有可还原的 AI 原文')
return
}
this.generatedContent = this.aiMarkdown
this.generatedTitle = this.aiTitle || this.generatedTitle
this.editMarkdown = this.aiMarkdown
this.hasEdited = false
this.persistDraft()
this.showToast('已还原为 AI 原文')
},
persistDraft() {
try {
const markdown = String(this.displayMarkdown || '').trim()
const title = String(this.generatedTitle || '').trim()
if (!markdown && !title) {
this.clearDraft()
return
}
uni.setStorageSync(DRAFT_KEY, {
title: this.generatedTitle,
markdown: this.displayMarkdown,
aiTitle: this.aiTitle,
aiMarkdown: this.aiMarkdown,
hasEdited: this.hasEdited
})
} catch (error) {
// 存不下就算了,不能因为本地存储报错挡住编辑
}
},
clearDraft() {
try {
uni.removeStorageSync(DRAFT_KEY)
} catch (error) {
// 同上
}
},
restoreDraft() {
try {
const draft = uni.getStorageSync(DRAFT_KEY)
if (
!draft ||
(!String(draft.markdown || '').trim() && !String(draft.title || '').trim())
) {
return false
}
this.generatedTitle = draft.title || ''
this.aiTitle = draft.aiTitle || ''
this.aiMarkdown = draft.aiMarkdown || ''
// AI 原文回填到生成结果:markdownContent 才是「原文」,状态与还原都靠它
this.generatedContent = draft.aiMarkdown || draft.markdown || ''
this.hasEdited = !!draft.hasEdited
this.editMarkdown = this.hasEdited ? draft.markdown || '' : ''
return true
} catch (error) {
return false
}
},
/** 有编辑版本时,重新生成要先问一句:那会把改动冲掉 */
confirmDiscardEdit() {
if (!this.editing && !this.hasEdited) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
uni.showModal({
title: '重新生成',
content: '当前有编辑过的内容,重新生成会丢弃这些修改。',
confirmText: '继续生成',
cancelText: '取消',
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false)
})
})
},
handleDocTypeChange(event) {
const index = Number(event.detail.value)
this.form.docType = DOC_TYPE_VALUES[index] || this.form.docType
@@ -549,6 +726,13 @@
clearGenerated() {
this.generatedTitle = ''
this.generatedContent = ''
// 编辑版本是这份正文的产物,正文清空了就不能留着
this.editing = false
this.editMarkdown = ''
this.hasEdited = false
this.aiMarkdown = ''
this.aiTitle = ''
this.clearDraft()
},
resetForm() {
this.form = createDefaultForm()
@@ -656,6 +840,9 @@
: '请至少填写稿件标题、亮点内容或上传附件中的一项')
return
}
if (!(await this.confirmDiscardEdit())) {
return
}
this.loading = true
this.clearGenerated()
this.generatedTitle = String(this.form.theme || '').trim()
@@ -672,6 +859,13 @@
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
this.resolveStream()
await waitStream
// 生成结束:这次的结果记为 AI 原文快照,旧编辑版本与草稿作废
this.aiMarkdown = this.markdownContent
this.aiTitle = this.generatedTitle
this.hasEdited = false
this.editMarkdown = ''
this.editing = false
this.clearDraft()
if (this.streamFailed) {
// 后端判定工作流失败,正文区已显示失败文案,这里同步提示
this.showToast('生成失败,请稍后重试')
@@ -717,12 +911,13 @@
}
},
copyGenerated() {
if (!this.markdownContent) {
// 复制的是当前生效版本:用户改过正文,复制的就该是改过的
if (!this.displayMarkdown) {
this.showToast('暂无可复制的正文内容')
return
}
uni.setClipboardData({
data: this.markdownContent,
data: this.displayMarkdown,
success: () => {
this.showToast('正文已复制')
}
@@ -850,12 +1045,24 @@
}
.field-label {
display: flex;
align-items: center;
margin-bottom: 12rpx;
font-size: 25rpx;
font-weight: 600;
color: #2b3037;
}
.field-label__badge {
margin-left: 12rpx;
padding: 2rpx 12rpx;
border-radius: 999rpx;
background: #eef9f1;
color: #1f8f49;
font-size: 20rpx;
font-weight: 600;
}
.field-picker,
.field-textarea {
width: 100%;
@@ -1069,4 +1276,33 @@
.result-empty {
color: #9a8f89;
}
.edit-hint {
margin-bottom: 14rpx;
font-size: 22rpx;
line-height: 1.6;
color: #9a8f89;
}
.result-editor {
width: 100%;
min-height: 420rpx;
font-size: 24rpx;
line-height: 1.85;
color: #2b3037;
}
/* 四个操作按钮排 2×2,多出来的「还原」也在同一套网格里 */
.result-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.result-btn--ghost {
background: #fff;
border: 1rpx solid #cfe7fb;
}
.result-btn--disabled {
opacity: 0.5;
}
</style>