公众号助手bug修复

This commit is contained in:
Jackzhou
2026-08-27 18:00:58 +08:00
parent f864cc2c87
commit 8dfcb6547c
5 changed files with 573 additions and 40 deletions
@@ -35,6 +35,7 @@ func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccoun
OutlineStyle: normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle),
OutlineWords: normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords),
ContentTargetWords: normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords),
MaxContentImages: normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages),
Status: "draft",
}
if err := store.DB.Create(row).Error; err != nil {
@@ -56,6 +57,7 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(article.OutlineStyle)
workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(article.OutlineWords)
workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(article.ContentTargetWords)
workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(article.MaxContentImages)
if strings.TrimSpace(article.TopicCandidatesJSON) != "" {
var items []officialAccountTopicCandidate
@@ -110,6 +112,7 @@ func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticl
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle)
article.OutlineWords = normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)
article.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
article.SelectedTopic = workflow.Shared.SelectedTopic
article.SelectedTitle = workflow.Shared.SelectedTitle
article.Outline = workflow.Shared.Outline
@@ -174,6 +177,7 @@ func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req offi
article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords)
article.Outline = ""
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
article.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages)
article.Content = ""
article.ContentWithPrompts = ""
article.ImagePromptsJSON = "[]"
@@ -147,6 +147,7 @@ func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowSta
func buildOfficialAccountImagePrompts(workflow *officialAccountWorkflowState) []officialAccountImagePrompt {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
maxContentImages := normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
prompts := []officialAccountImagePrompt{
{
Key: "cover",
@@ -155,7 +156,7 @@ func buildOfficialAccountImagePrompts(workflow *officialAccountWorkflowState) []
},
}
for idx, item := range sections {
if idx >= 3 {
if maxContentImages > 0 && idx >= maxContentImages {
break
}
prompts = append(prompts, officialAccountImagePrompt{
@@ -46,6 +46,7 @@ type officialAccountTaskCreateReq struct {
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
MaxContentImages int `json:"max_content_images"`
}
type officialAccountStepReq struct {
@@ -67,6 +68,7 @@ type officialAccountTaskUpdateReq struct {
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
MaxContentImages int `json:"max_content_images"`
}
type officialAccountWorkflowState struct {
@@ -87,6 +89,7 @@ type officialAccountForm struct {
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
MaxContentImages int `json:"max_content_images"`
}
type officialAccountSharedState struct {
@@ -275,7 +278,8 @@ func UpdateOfficialAccountTask(c *gin.Context) {
oldWorkflow.Form.Requirements != req.Requirements ||
oldWorkflow.Form.OutlineStyle != normalizeOfficialAccountOutlineStyle(req.OutlineStyle) ||
oldWorkflow.Form.OutlineWords != normalizeOfficialAccountOutlineWords(req.OutlineWords) ||
oldWorkflow.Form.ContentTargetWords != normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
oldWorkflow.Form.ContentTargetWords != normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) ||
oldWorkflow.Form.MaxContentImages != normalizeOfficialAccountMaxContentImages(req.MaxContentImages)
workflow := oldWorkflow
if changed {
@@ -289,6 +293,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
OutlineStyle: req.OutlineStyle,
OutlineWords: req.OutlineWords,
ContentTargetWords: req.ContentTargetWords,
MaxContentImages: req.MaxContentImages,
})
} else {
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
@@ -300,6 +305,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle)
workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords)
workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages)
}
if changed {
@@ -352,6 +358,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
"outline_style": normalizeOfficialAccountOutlineStyle(req.OutlineStyle),
"outline_words": normalizeOfficialAccountOutlineWords(req.OutlineWords),
"content_target_words": normalizeOfficialAccountContentTargetWords(req.ContentTargetWords),
"max_content_images": normalizeOfficialAccountMaxContentImages(req.MaxContentImages),
})
logsJSON, _ := json.Marshal([]string{
fmt.Sprintf("%s 已切换主题并重置流程", now.Format("15:04:05")),
@@ -556,6 +563,7 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
OutlineStyle: normalizeOfficialAccountOutlineStyle(req.OutlineStyle),
OutlineWords: normalizeOfficialAccountOutlineWords(req.OutlineWords),
ContentTargetWords: normalizeOfficialAccountContentTargetWords(req.ContentTargetWords),
MaxContentImages: normalizeOfficialAccountMaxContentImages(req.MaxContentImages),
},
Steps: map[string]*officialAccountStepState{},
Shared: officialAccountSharedState{
@@ -611,6 +619,16 @@ func normalizeOfficialAccountContentTargetWords(value int) int {
return value
}
func normalizeOfficialAccountMaxContentImages(value int) int {
if value <= 0 {
return 0
}
if value > 20 {
return 20
}
return value
}
func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState {
if strings.TrimSpace(value) == "" {
return newOfficialAccountWorkflow(officialAccountTaskCreateReq{})
@@ -634,6 +652,7 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState {
if workflow.Form.Tone == "" {
workflow.Form.Tone = "专业但好懂"
}
workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
if workflow.Steps == nil {
workflow.Steps = map[string]*officialAccountStepState{}
}
@@ -23,6 +23,7 @@ type OfficialAccountArticle struct {
OutlineWords int `gorm:"not null;default:600" json:"outline_words"`
Outline string `gorm:"type:text" json:"outline"`
ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"`
MaxContentImages int `gorm:"not null;default:0" json:"max_content_images"`
Content string `gorm:"type:text" json:"content"`
ContentWithPrompts string `gorm:"type:text" json:"content_with_prompts"`
ImagePromptsJSON string `gorm:"type:text" json:"image_prompts_json"`
@@ -16,6 +16,14 @@
<el-input v-model="taskForm.audience" placeholder="目标受众,例如:企业老板、销售顾问" />
<el-input v-model="taskForm.goal" placeholder="内容目标,例如:做认知、做转化、做留资" />
<el-input v-model="taskForm.tone" placeholder="内容语气,例如:专业但好懂" />
<el-select v-model="taskForm.maxContentImages" placeholder="请选择配图上限">
<el-option
v-for="option in maxContentImageOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
<div class="panel-subtitle">
当前将按「{{ taskResolvedBusinessDomain.label }}」处理热点源、评分规则和 Prompt
@@ -80,7 +88,7 @@
</template>
<template #node-official-step="{ id, data }">
<div class="oa-node" :class="[data.statusClass, { selected: selectedNodeKey === id, bubbleOpen: isNodeExpanded(id) }]">
<Handle type="target" :position="Position.Left" />
<Handle type="target" :position="data.targetPosition" />
<div class="oa-node-head">
<span class="oa-node-index">{{ data.index }}</span>
<span class="oa-node-badge" :class="data.statusClass">{{ data.statusText }}</span>
@@ -88,7 +96,11 @@
<div class="oa-node-title">{{ data.label }}</div>
<div class="oa-node-desc">{{ data.preview }}</div>
<div class="oa-node-progress">
<div class="oa-node-progress-fill" :style="{ width: `${data.progress}%` }" />
<div
class="oa-node-progress-fill"
:class="{ 'is-running': data.progressActive }"
:style="{ width: `${data.progress}%` }"
/>
</div>
<div class="oa-node-foot">{{ data.progressText }}</div>
<div class="oa-node-actions">
@@ -232,6 +244,10 @@
<span>目标篇幅</span>
<strong>提纲 {{ configForm.outlineWords || 600 }} 字 / 正文 {{ configForm.contentTargetWords || 1400 }} 字</strong>
</div>
<div class="summary-item">
<span>内容配图上限</span>
<strong>{{ formatMaxContentImages(configForm.maxContentImages) }}</strong>
</div>
</div>
<div class="config-form-grid">
<div class="config-field">
@@ -257,6 +273,19 @@
<el-option v-for="option in contentWordOptions" :key="option" :label="`${option} 字`" :value="option" />
</el-select>
</div>
<div class="config-field">
<div class="config-label">内容配图上限</div>
<div class="config-tip">默认按正文段落数动态生成,也可以手动限制最多生成多少张内容配图。</div>
<el-select v-model="configForm.maxContentImages" placeholder="请选择配图上限">
<el-option
v-for="option in maxContentImageOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
</div>
<div class="action-row">
<el-button type="primary" :loading="savingConfig" @click.stop="saveTaskConfig">保存任务配置</el-button>
@@ -353,10 +382,32 @@
<template v-else-if="id === stepKeyImages">
<div class="node-panel-title">图片结果</div>
<div class="inline-meta">当前已生成 {{ (workflow.shared.image_refs || []).length }} 张图片</div>
<div class="inline-meta">
当前已生成 {{ (workflow.shared.image_refs || []).length }} 张图片
<template v-if="(workflow.shared.image_refs || []).length">
· 预览成功 {{ imagePreviewLoadedCount }} 张
<template v-if="imagePreviewFailedCount"> · 失败 {{ imagePreviewFailedCount }} 张</template>
</template>
</div>
<div v-if="(workflow.shared.image_refs || []).length" class="image-grid">
<div v-for="item in workflow.shared.image_refs || []" :key="item.key" class="image-card">
<img class="image-thumb" :src="item.url" :alt="item.section || item.key" />
<div class="image-status-row">
<div class="image-status-chip" :class="`is-${getImagePreviewStatus(item).state}`">
{{ getImagePreviewStatus(item).label }}
</div>
</div>
<div v-if="getImagePreviewStatus(item).state === 'loading'" class="image-thumb image-thumb-placeholder">
图片预览加载中...
</div>
<div v-else-if="getImagePreviewStatus(item).state === 'error'" class="image-thumb image-thumb-placeholder is-error">
{{ getImagePreviewStatus(item).message || '图片预览失败,建议重新生成' }}
</div>
<img
v-else
class="image-thumb"
:src="getImagePreviewSource(item)"
:alt="item.section || item.key"
/>
<div class="image-section">{{ item.section || item.key }}</div>
<div class="image-prompt">{{ item.prompt }}</div>
</div>
@@ -382,7 +433,7 @@
导出预览 HTML
</el-button>
</div>
<div v-if="workflow.shared.preview_html" class="preview-render" v-html="workflow.shared.preview_html" />
<div v-if="workflow.shared.preview_html" class="preview-render" v-html="resolvedPreviewHtml" />
<div v-else class="empty-text">当前还没有生成预览稿。</div>
</template>
@@ -423,7 +474,7 @@
</div>
</div>
</div>
<Handle type="source" :position="Position.Right" />
<Handle type="source" :position="data.sourcePosition" />
</div>
</template>
</VueFlow>
@@ -437,7 +488,7 @@
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import { BaseEdge, EdgeLabelRenderer, VueFlow, Handle, Position, MarkerType, getSmoothStepPath } from '@vue-flow/core'
@@ -466,6 +517,16 @@ const stepKeyImages = 'image_generation'
const stepKeyPreview = 'preview_export'
const stepKeyPageMD = 'page_markdown'
const stepKeyConfig = 'task_configuration'
const workflowStepKeys = [
stepKeyTopic,
stepKeyTitle,
stepKeyOutline,
stepKeyContent,
stepKeyImagePrompts,
stepKeyImages,
stepKeyPreview,
stepKeyPageMD,
]
const creatingTask = ref(false)
const loadingWorkflow = ref(false)
@@ -479,12 +540,20 @@ const fittedTaskId = ref('')
const canvasNodes = ref([])
const canvasEdges = ref([])
const canvasZoomOnScroll = ref(true)
const canvasTranslateExtent = [[-320, -260], [4680, 1680]]
const canvasNodeExtent = [[40, 80], [4320, 1320]]
const canvasTranslateExtent = [[-320, -260], [4080, 1920]]
const canvasNodeExtent = [[40, 80], [3520, 1480]]
const expandedHotspotKeys = ref([])
const pollingToken = ref(0)
const pollingStepKey = ref('')
const exportingFormat = ref('')
const imagePreviewSources = ref({})
const imageExportSources = ref({})
const imagePreviewStates = ref({})
const runningStepStartedAt = ref({})
const stepProgressHoldUntil = ref({})
const progressClock = ref(Date.now())
let progressClockTimer = null
const minRunningProgressVisibleMs = 3200
const taskForm = reactive({
keyword: '',
@@ -496,6 +565,7 @@ const taskForm = reactive({
outlineStyle: '问题拆解型',
outlineWords: 600,
contentTargetWords: 1400,
maxContentImages: 0,
})
const stepPayload = reactive({
@@ -517,6 +587,7 @@ const configForm = reactive({
outlineStyle: '问题拆解型',
outlineWords: 600,
contentTargetWords: 1400,
maxContentImages: 0,
})
const businessDomainOptions = [
@@ -581,6 +652,13 @@ const outlineStyleOptions = [
const outlineWordOptions = [300, 500, 600, 800, 1000]
const contentWordOptions = [1000, 1200, 1400, 1800, 2200]
const maxContentImageOptions = [
{ label: '动态(按正文段落)', value: 0 },
{ label: '最多 3 张内容图', value: 3 },
{ label: '最多 5 张内容图', value: 5 },
{ label: '最多 8 张内容图', value: 8 },
{ label: '最多 12 张内容图', value: 12 },
]
const workspaceKey = computed(() => route.meta.workspaceKey || 'wechat-official-account')
const selectedTaskId = computed(() => {
@@ -600,6 +678,10 @@ const taskSummary = computed(() => task.value?.summary || '通过节点式画布
const taskStatusText = computed(() => task.value?.status || '待创建')
const completedStepCount = computed(() => Object.values(workflow.value?.steps || {}).filter(item => item.status === 'completed').length)
const currentStepLabel = computed(() => stepLabel(workflow.value?.current_step || stepKeyTopic))
const imagePreviewItems = computed(() => workflow.value?.shared?.image_refs || [])
const imagePreviewLoadedCount = computed(() => imagePreviewItems.value.filter(item => getImagePreviewStatus(item).state === 'loaded').length)
const imagePreviewFailedCount = computed(() => imagePreviewItems.value.filter(item => getImagePreviewStatus(item).state === 'error').length)
const resolvedPreviewHtml = computed(() => replacePreviewHtmlImageSources(workflow.value?.shared?.preview_html || '', 'display'))
const workflowSteps = computed(() => {
const steps = workflow.value?.steps || {}
@@ -623,6 +705,7 @@ function getOutputRows(stepKey) {
{ label: '当前标题', value: workflow.value?.shared?.selected_title || '未选择' },
{ label: '提纲风格', value: article.value?.outline_style || configForm.outlineStyle || '问题拆解型' },
{ label: '正文字数', value: `${article.value?.content_target_words || configForm.contentTargetWords || 1400} 字` },
{ label: '内容配图上限', value: formatMaxContentImages(article.value?.max_content_images ?? configForm.maxContentImages) },
]
}
const step = workflowSteps.value.find(item => item.key === stepKey)
@@ -693,17 +776,25 @@ const flowNodes = computed(() => {
})),
]
return nodes.map((item, index) => ({
id: item.id,
type: 'official-step',
position: { x: 80 + index * 410, y: 150 },
selectable: true,
draggable: true,
data: {
...item,
bubbleAlign: getNodeBubbleAlign(item.id),
},
}))
return nodes.map((item, index) => {
const isBottomRow = isBottomRowStep(item.id)
return {
id: item.id,
type: 'official-step',
class: isNodeExpanded(item.id) ? 'node-expanded' : '',
position: getFlowNodePosition(item.id),
selectable: true,
draggable: true,
sourcePosition: isBottomRow ? Position.Left : Position.Right,
targetPosition: isBottomRow ? Position.Right : Position.Left,
data: {
...item,
bubbleAlign: getNodeBubbleAlign(item.id),
sourcePosition: isBottomRow ? Position.Left : Position.Right,
targetPosition: isBottomRow ? Position.Right : Position.Left,
},
}
})
})
const flowEdges = computed(() => {
@@ -749,8 +840,9 @@ watch(flowEdges, (value) => {
}, { immediate: true })
function buildStepCard(index, key, state) {
const status = state?.status || 'pending'
const status = getDisplayStepStatus(key, state)
const rerunnable = status === 'completed' || status === 'error'
const progress = getDisplayStepProgress(key, state)
return {
index,
key,
@@ -758,8 +850,9 @@ function buildStepCard(index, key, state) {
status,
statusText: stepStatusText(status),
statusClass: `status-${status}`,
progress: state?.progress || 0,
progressText: state?.progress_text || '等待执行',
progress,
progressActive: isStepProgressActive(key, status),
progressText: getStepProgressText(key, state, progress),
logs: state?.logs || [],
output: state?.output || {},
preview: stepPreview(key),
@@ -781,6 +874,11 @@ function stepPreview(key) {
return '等待执行'
}
function formatMaxContentImages(value) {
const count = Number(value) || 0
return count > 0 ? `最多 ${count} 张内容图` : '动态(按正文段落)'
}
function canRunStep(key) {
const activeRunningKey = getActiveRunningStepKey()
if (activeRunningKey) return activeRunningKey === key
@@ -808,6 +906,123 @@ function stepHintText(key) {
return '当前节点暂不可执行'
}
function markStepRunning(stepKey) {
if (!stepKey) return
if (runningStepStartedAt.value[stepKey]) return
runningStepStartedAt.value = {
...runningStepStartedAt.value,
[stepKey]: Date.now(),
}
}
function holdStepProgress(stepKey) {
if (!stepKey) return
const startedAt = runningStepStartedAt.value[stepKey]
if (!startedAt) return
const holdUntil = startedAt + minRunningProgressVisibleMs
if (holdUntil <= Date.now()) return
stepProgressHoldUntil.value = {
...stepProgressHoldUntil.value,
[stepKey]: holdUntil,
}
}
function clearStepRunning(stepKey) {
if (!stepKey || !runningStepStartedAt.value[stepKey]) return
const next = { ...runningStepStartedAt.value }
delete next[stepKey]
runningStepStartedAt.value = next
}
function clearStepProgressHold(stepKey) {
if (!stepKey || !stepProgressHoldUntil.value[stepKey]) return
const next = { ...stepProgressHoldUntil.value }
delete next[stepKey]
stepProgressHoldUntil.value = next
}
function releaseStepProgressState(stepKey) {
clearStepProgressHold(stepKey)
clearStepRunning(stepKey)
}
function isStepProgressHolding(stepKey) {
return Number(stepProgressHoldUntil.value[stepKey] || 0) > progressClock.value
}
function pruneExpiredStepProgressState(now = Date.now()) {
const expiredKeys = Object.entries(stepProgressHoldUntil.value)
.filter(([, holdUntil]) => Number(holdUntil) <= now)
.map(([key]) => key)
expiredKeys.forEach(key => releaseStepProgressState(key))
}
function shouldKeepLocalProgress(stepKey) {
const startedAt = Number(runningStepStartedAt.value[stepKey] || 0)
return startedAt > 0 && progressClock.value - startedAt < minRunningProgressVisibleMs
}
function syncRunningStepProgress(workflowState = workflow.value) {
const currentRunningKey = findRunningStepKey(workflowState)
workflowStepKeys.forEach((key) => {
const isLocallyActive = runningStepKey.value === key || pollingStepKey.value === key
if (key === currentRunningKey || isLocallyActive) {
markStepRunning(key)
return
}
if (!isStepProgressHolding(key) && !shouldKeepLocalProgress(key)) {
clearStepRunning(key)
}
})
}
function isStepProgressActive(stepKey, status = workflow.value?.steps?.[stepKey]?.status) {
return status === 'running'
|| runningStepKey.value === stepKey
|| pollingStepKey.value === stepKey
|| isStepProgressHolding(stepKey)
|| shouldKeepLocalProgress(stepKey)
}
function getDisplayStepStatus(stepKey, state) {
const rawStatus = state?.status || 'pending'
if (rawStatus !== 'error' && isStepProgressActive(stepKey, rawStatus)) {
return 'running'
}
return rawStatus
}
function getSimulatedRunningProgress(stepKey) {
const startedAt = runningStepStartedAt.value[stepKey] || progressClock.value
const elapsed = Math.max(0, progressClock.value - startedAt)
const easedProgress = 4 + (91 - 4) * (1 - Math.exp(-elapsed / 7200))
return Math.min(91, Math.round(easedProgress))
}
function getDisplayStepProgress(stepKey, state) {
const status = getDisplayStepStatus(stepKey, state)
const rawProgress = normalizeStepProgress(state?.progress)
if (status === 'running') return getSimulatedRunningProgress(stepKey)
if (status === 'completed') return 100
return rawProgress
}
function normalizeCompletedProgressText(rawText) {
const cleaned = String(rawText || '')
.replace(/\s*[·•]?\s*\d+%\s*$/u, '')
.trim()
return cleaned || '已完成'
}
function getStepProgressText(stepKey, state, progress) {
const status = getDisplayStepStatus(stepKey, state)
const rawText = String(state?.progress_text || '').trim()
if (status === 'running') return `正在处理中 · ${progress}%`
if (status === 'completed') return normalizeCompletedProgressText(rawText)
if (status === 'error') return rawText || '执行失败'
return rawText || '等待执行'
}
function getActiveRunningStepKey() {
return runningStepKey.value || pollingStepKey.value || findRunningStepKey()
}
@@ -867,7 +1082,7 @@ function handleCanvasPointerDown(event) {
}
function fitCanvas() {
flowInstance.value?.fitView?.({ padding: 0.04, duration: 250, maxZoom: 1.08 })
flowInstance.value?.fitView?.({ padding: 0.035, duration: 250, maxZoom: 1.14 })
}
function isNodeExpanded(stepKey) {
@@ -898,6 +1113,33 @@ function getNodeBubbleWidth(stepKey) {
return 500
}
const flowNodeWidth = 360
const flowLeftX = 80
const flowTopY = 150
const flowBottomY = 470
const flowColumnGap = 470
function isBottomRowStep(stepKey) {
return [stepKeyImagePrompts, stepKeyImages, stepKeyPreview, stepKeyPageMD].includes(stepKey)
}
function getFlowNodePosition(stepKey) {
const topRow = {
[stepKeyConfig]: { x: flowLeftX + flowColumnGap * 0, y: flowTopY },
[stepKeyTopic]: { x: flowLeftX + flowColumnGap * 1, y: flowTopY },
[stepKeyTitle]: { x: flowLeftX + flowColumnGap * 2, y: flowTopY },
[stepKeyOutline]: { x: flowLeftX + flowColumnGap * 3, y: flowTopY },
[stepKeyContent]: { x: flowLeftX + flowColumnGap * 4, y: flowTopY },
}
const bottomRow = {
[stepKeyImagePrompts]: { x: flowLeftX + flowColumnGap * 4, y: flowBottomY },
[stepKeyImages]: { x: flowLeftX + flowColumnGap * 3, y: flowBottomY },
[stepKeyPreview]: { x: flowLeftX + flowColumnGap * 2, y: flowBottomY },
[stepKeyPageMD]: { x: flowLeftX + flowColumnGap * 1, y: flowBottomY },
}
return topRow[stepKey] || bottomRow[stepKey] || { x: flowLeftX, y: flowTopY }
}
function getPromptSlotLabel(item, index) {
if (item?.key === 'cover' || index === 0) return '封面配图'
return `内容配图 ${index}`
@@ -911,6 +1153,129 @@ function getPromptPurpose(item, index) {
return `用于“${sectionName}”这一段的说明配图,帮助读者更直观理解内容`
}
function getImagePreviewKey(item) {
return `${item?.key || 'image'}::${item?.url || ''}`
}
function getImagePreviewStatus(item) {
const status = imagePreviewStates.value[getImagePreviewKey(item)]
if (!status) return { state: 'loading', label: '预览加载中', message: '' }
if (status.state === 'loaded') return { state: 'loaded', label: '预览成功', message: '' }
if (status.state === 'error') return { state: 'error', label: '预览失败', message: status.message || '图片预览失败,建议重新生成' }
return { state: 'loading', label: '预览加载中', message: '' }
}
function getImagePreviewSource(item) {
return imagePreviewSources.value[getImagePreviewKey(item)] || item?.url || ''
}
function getImageExportSource(item) {
return imageExportSources.value[getImagePreviewKey(item)] || item?.url || ''
}
function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : '')
reader.onerror = () => reject(new Error('图片转 data URL 失败'))
reader.readAsDataURL(blob)
})
}
function findImagePreviewItemByUrl(rawUrl) {
const normalized = String(rawUrl || '').trim()
if (!normalized) return null
return imagePreviewItems.value.find(item => String(item?.url || '').trim() === normalized) || null
}
function replacePreviewHtmlImageSources(rawHtml = '', mode = 'display') {
if (!String(rawHtml || '').trim()) return ''
if (typeof window === 'undefined' || typeof DOMParser === 'undefined') return rawHtml
const parser = new DOMParser()
const doc = parser.parseFromString(rawHtml, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
const rawSrc = String(img.getAttribute('src') || '').trim()
const item = findImagePreviewItemByUrl(rawSrc)
if (!item) return
const nextSrc = mode === 'export' ? getImageExportSource(item) : getImagePreviewSource(item)
if (nextSrc) {
img.setAttribute('src', nextSrc)
}
})
return doc.body.innerHTML || rawHtml
}
function revokeImagePreviewSources() {
Object.values(imagePreviewSources.value).forEach((value) => {
if (typeof value === 'string' && value.startsWith('blob:')) {
URL.revokeObjectURL(value)
}
})
imagePreviewSources.value = {}
imageExportSources.value = {}
}
async function refreshImagePreviews(items = imagePreviewItems.value) {
revokeImagePreviewSources()
imagePreviewStates.value = {}
const list = Array.isArray(items) ? items : []
if (!list.length) return
const token = localStorage.getItem('token') || ''
const nextSources = {}
const nextExportSources = {}
const nextStates = {}
await Promise.all(list.map(async (item) => {
const previewKey = getImagePreviewKey(item)
nextStates[previewKey] = { state: 'loading', message: '' }
const rawUrl = String(item?.url || '').trim()
if (!rawUrl) {
nextStates[previewKey] = { state: 'error', message: '没有拿到图片地址,建议重新生成' }
return
}
try {
if (/^https?:\/\//i.test(rawUrl) || rawUrl.startsWith('data:image/')) {
nextSources[previewKey] = rawUrl
nextExportSources[previewKey] = rawUrl
} else {
const response = await fetch(rawUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!response.ok) {
throw new Error(`图片接口返回 ${response.status}`)
}
const blob = await response.blob()
nextSources[previewKey] = URL.createObjectURL(blob)
nextExportSources[previewKey] = await blobToDataURL(blob)
}
nextStates[previewKey] = { state: 'loaded', message: '' }
} catch (error) {
nextStates[previewKey] = {
state: 'error',
message: error?.message || '图片预览失败,建议重新生成',
}
}
}))
imagePreviewSources.value = nextSources
imageExportSources.value = nextExportSources
imagePreviewStates.value = nextStates
}
async function ensurePreviewExportSources(items = imagePreviewItems.value) {
const list = Array.isArray(items) ? items : []
if (!list.length) return
const hasMissingProtectedImage = list.some((item) => {
const rawUrl = String(item?.url || '').trim()
if (!rawUrl || /^https?:\/\//i.test(rawUrl) || rawUrl.startsWith('data:image/')) return false
return !imageExportSources.value[getImagePreviewKey(item)]
})
if (hasMissingProtectedImage) {
await refreshImagePreviews(list)
}
}
function handleNodePanelWheel() {
canvasZoomOnScroll.value = false
}
@@ -934,8 +1299,40 @@ function getProgressEdgeGeometry(edgeProps) {
}
}
function getEdgeBubblePosition(sourceKey, targetKey, edgeProps) {
const sourceNode = getFlowNodePosition(sourceKey)
const targetNode = getFlowNodePosition(targetKey)
const sourceIsBottomRow = isBottomRowStep(sourceKey)
const targetIsBottomRow = isBottomRowStep(targetKey)
if (sourceNode.y === targetNode.y) {
const sourceEdgeX = sourceIsBottomRow ? sourceNode.x : sourceNode.x + flowNodeWidth
const targetEdgeX = targetIsBottomRow ? targetNode.x + flowNodeWidth : targetNode.x
return {
x: (sourceEdgeX + targetEdgeX) / 2,
y: edgeProps.labelY || (edgeProps.sourceY + edgeProps.targetY) / 2,
}
}
if (sourceKey === stepKeyContent && targetKey === stepKeyImagePrompts) {
return {
x: sourceNode.x + flowNodeWidth + 34,
y: (edgeProps.sourceY + edgeProps.targetY) / 2,
}
}
return null
}
function getEdgeBubbleStyle(edgeProps) {
const { labelX, labelY } = getProgressEdgeGeometry(edgeProps)
const geometry = getProgressEdgeGeometry(edgeProps)
const bubblePosition = getEdgeBubblePosition(edgeProps.source, edgeProps.target, {
...edgeProps,
labelX: geometry.labelX,
labelY: geometry.labelY,
})
const labelX = bubblePosition?.x ?? geometry.labelX
const labelY = bubblePosition?.y ?? geometry.labelY
return {
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY - 18}px)`,
}
@@ -960,8 +1357,8 @@ function normalizeStepProgress(progress) {
function getEdgeProgressMeta(sourceKey, targetKey) {
const targetStep = workflow.value?.steps?.[targetKey]
const targetStatus = targetStep?.status || 'pending'
const progress = normalizeStepProgress(targetStep?.progress)
const targetStatus = getDisplayStepStatus(targetKey, targetStep)
const progress = getDisplayStepProgress(targetKey, targetStep)
const labelPrefix = stepShortLabel(targetKey)
const sourceCompleted = sourceKey === stepKeyConfig || workflow.value?.steps?.[sourceKey]?.status === 'completed'
@@ -1006,6 +1403,7 @@ async function createTask() {
outline_style: taskForm.outlineStyle,
outline_words: taskForm.outlineWords,
content_target_words: taskForm.contentTargetWords,
max_content_images: Number(taskForm.maxContentImages) || 0,
})
const taskData = res?.data?.task
if (!taskData?.id) throw new Error('任务创建成功,但未返回任务 ID')
@@ -1033,6 +1431,7 @@ async function loadWorkflow(taskId = selectedTaskId.value, options = {}) {
try {
const res = await getOfficialAccountWorkflow(taskId)
workflowDetail.value = res?.data || null
syncRunningStepProgress(res?.data?.workflow)
syncSelectionsFromWorkflow()
if (!preserveNodeState) {
selectedNodeKey.value = workflow.value?.current_step || stepKeyTopic
@@ -1077,6 +1476,7 @@ function syncSelectionsFromWorkflow() {
configForm.outlineStyle = workflow.value?.form?.outline_style || '问题拆解型'
configForm.outlineWords = workflow.value?.form?.outline_words || 600
configForm.contentTargetWords = workflow.value?.form?.content_target_words || 1400
configForm.maxContentImages = Number(workflow.value?.form?.max_content_images) || 0
}
async function saveTaskConfig() {
@@ -1100,6 +1500,7 @@ async function saveTaskConfig() {
outline_style: configForm.outlineStyle,
outline_words: Number(configForm.outlineWords) || 600,
content_target_words: Number(configForm.contentTargetWords) || 1400,
max_content_images: Number(configForm.maxContentImages) || 0,
})
workflowDetail.value = res?.data || null
syncSelectionsFromWorkflow()
@@ -1162,6 +1563,7 @@ async function runStep(stepKey) {
try {
runningStepKey.value = stepKey
pollingStepKey.value = stepKey
markStepRunning(stepKey)
await nextTick()
ElMessage.success(`${stepLabel(stepKey)}已开始执行`)
await executeOfficialAccountWorkflowStep(selectedTaskId.value, stepKey, {
@@ -1175,6 +1577,7 @@ async function runStep(stepKey) {
await loadWorkflow(selectedTaskId.value, { silent: true, preserveNodeState: true, syncRuntime: false })
await pollWorkflowUntilStepSettled(String(selectedTaskId.value), stepKey, { notifyOnFinish: true })
} catch (error) {
releaseStepProgressState(stepKey)
runningStepKey.value = ''
pollingStepKey.value = ''
ElMessage.error(error?.message || `${stepLabel(stepKey)}执行失败`)
@@ -1184,15 +1587,20 @@ async function runStep(stepKey) {
function findRunningStepKey(workflowState = workflow.value) {
const steps = workflowState?.steps || {}
return [stepKeyTopic, stepKeyTitle, stepKeyOutline, stepKeyContent, stepKeyImagePrompts, stepKeyImages, stepKeyPreview, stepKeyPageMD]
.find(key => steps?.[key]?.status === 'running') || ''
return workflowStepKeys.find(key => steps?.[key]?.status === 'running') || ''
}
async function downloadExport(format, filename) {
if (!selectedTaskId.value) return
try {
exportingFormat.value = format
const blob = await exportOfficialAccountDocument(selectedTaskId.value, format)
let blob = await exportOfficialAccountDocument(selectedTaskId.value, format)
if (format === 'preview_html') {
await ensurePreviewExportSources()
const rawHtml = await blob.text()
const resolvedHtml = replacePreviewHtmlImageSources(rawHtml, 'export')
blob = new Blob([resolvedHtml], { type: 'text/html;charset=utf-8' })
}
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@@ -1215,6 +1623,7 @@ async function pollWorkflowUntilStepSettled(taskId, stepKey, options = {}) {
pollingToken.value = token
pollingStepKey.value = stepKey
runningStepKey.value = stepKey
markStepRunning(stepKey)
for (let attempt = 0; attempt < 240; attempt += 1) {
await wait(2000)
@@ -1224,6 +1633,14 @@ async function pollWorkflowUntilStepSettled(taskId, stepKey, options = {}) {
await loadWorkflow(taskId, { silent: true, preserveNodeState: true, syncRuntime: false })
const status = workflow.value?.steps?.[stepKey]?.status
if (status && status !== 'running') {
if (status === 'completed') {
holdStepProgress(stepKey)
if (!isStepProgressHolding(stepKey)) {
releaseStepProgressState(stepKey)
}
} else {
releaseStepProgressState(stepKey)
}
pollingStepKey.value = ''
runningStepKey.value = ''
await workerRuntime.loadTasks(workspaceKey.value)
@@ -1240,6 +1657,7 @@ async function pollWorkflowUntilStepSettled(taskId, stepKey, options = {}) {
}
if (pollingToken.value === token) {
releaseStepProgressState(stepKey)
pollingStepKey.value = ''
runningStepKey.value = ''
ElMessage.warning(`${stepLabel(stepKey)}仍在后台执行,请稍后刷新查看结果`)
@@ -1302,9 +1720,25 @@ watch(selectedTaskId, () => {
}
}, { immediate: true })
watch(imagePreviewItems, (items) => {
void refreshImagePreviews(items)
}, { immediate: true, deep: true })
onMounted(async () => {
progressClockTimer = window.setInterval(() => {
progressClock.value = Date.now()
pruneExpiredStepProgressState(progressClock.value)
}, 450)
await workerRuntime.loadTasks(workspaceKey.value)
})
onBeforeUnmount(() => {
if (progressClockTimer) {
window.clearInterval(progressClockTimer)
progressClockTimer = null
}
revokeImagePreviewSources()
})
</script>
<style scoped>
@@ -1463,8 +1897,8 @@ onMounted(async () => {
}
.left-node-status.status-completed {
background: rgba(103, 194, 58, 0.12);
color: #409800;
background: rgba(34, 197, 94, 0.18);
color: #166534;
}
.left-node-status.status-running {
@@ -1785,7 +2219,37 @@ onMounted(async () => {
gap: 12px;
}
.image-status-row {
display: flex;
justify-content: flex-end;
}
.image-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.image-status-chip.is-loading {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.image-status-chip.is-loaded {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
}
.image-status-chip.is-error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.image-thumb {
margin-top: 8px;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
@@ -1794,6 +2258,23 @@ onMounted(async () => {
background: #e2e8f0;
}
.image-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
font-size: 13px;
line-height: 1.7;
text-align: center;
color: #64748b;
}
.image-thumb-placeholder.is-error {
border-color: rgba(248, 113, 113, 0.45);
background: rgba(254, 242, 242, 0.9);
color: #b91c1c;
}
.action-row {
margin-top: 8px;
display: flex;
@@ -1911,7 +2392,8 @@ onMounted(async () => {
}
.oa-node.status-completed {
background: #f6fff7;
background: #eefbf1;
border-color: rgba(134, 239, 172, 0.78);
}
.oa-node.status-running {
@@ -1944,8 +2426,8 @@ onMounted(async () => {
}
.oa-node-badge.status-completed {
background: rgba(103, 194, 58, 0.12);
color: #409800;
background: rgba(34, 197, 94, 0.18);
color: #166534;
}
.oa-node-badge.status-running {
@@ -1985,6 +2467,14 @@ onMounted(async () => {
height: 100%;
border-radius: inherit;
background: #2563eb;
transition: width 0.45s ease;
position: relative;
}
.oa-node-progress-fill.is-running {
background: linear-gradient(90deg, #2563eb 0%, #60a5fa 45%, #93c5fd 55%, #2563eb 100%);
background-size: 220% 100%;
animation: oa-progress-flow 1.6s linear infinite;
}
.oa-node-foot {
@@ -2001,6 +2491,16 @@ onMounted(async () => {
align-items: center;
}
@keyframes oa-progress-flow {
0% {
background-position: 200% 0;
}
100% {
background-position: -20% 0;
}
}
.node-expand-button {
margin-left: auto;
padding-right: 8px;
@@ -2111,9 +2611,9 @@ onMounted(async () => {
}
.oa-edge-bubble.is-completed {
border-color: rgba(134, 239, 172, 0.95);
background: rgba(240, 253, 244, 0.96);
color: #15803d;
border-color: rgba(74, 222, 128, 0.95);
background: rgba(220, 252, 231, 0.98);
color: #166534;
}
.oa-edge-bubble.is-error {
@@ -2141,6 +2641,14 @@ onMounted(async () => {
border: 2px solid #3b82f6 !important;
}
:deep(.vue-flow__node) {
overflow: visible !important;
}
:deep(.vue-flow__node.node-expanded) {
z-index: 40 !important;
}
:deep(.vue-flow__controls),
:deep(.vue-flow__minimap),
:deep(.vue-flow__attribution) {