@@ -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.target Position" / >
< 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.p review_h tml" / >
< div v-if = "workflow.shared.preview_html" class="preview-render" v-html="resolvedP reviewH tml" / >
< 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.source Position" / >
< / 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 : 12 px ;
}
. image - status - row {
display : flex ;
justify - content : flex - end ;
}
. image - status - chip {
display : inline - flex ;
align - items : center ;
padding : 4 px 10 px ;
border - radius : 999 px ;
font - size : 12 px ;
font - weight : 700 ;
}
. image - status - chip . is - loading {
background : rgba ( 37 , 99 , 235 , 0.1 ) ;
color : # 2563 eb ;
}
. image - status - chip . is - loaded {
background : rgba ( 34 , 197 , 94 , 0.12 ) ;
color : # 15803 d ;
}
. image - status - chip . is - error {
background : rgba ( 239 , 68 , 68 , 0.12 ) ;
color : # b91c1c ;
}
. image - thumb {
margin - top : 8 px ;
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 : 16 px ;
font - size : 13 px ;
line - height : 1.7 ;
text - align : center ;
color : # 64748 b ;
}
. 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 : 8 px ;
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 : # 2563 eb ;
transition : width 0.45 s ease ;
position : relative ;
}
. oa - node - progress - fill . is - running {
background : linear - gradient ( 90 deg , # 2563 eb 0 % , # 60 a5fa 45 % , # 93 c5fd 55 % , # 2563 eb 100 % ) ;
background - size : 220 % 100 % ;
animation : oa - progress - flow 1.6 s 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 : 8 px ;
@@ -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 : # 15803 d ;
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 : 2 px solid # 3 b82f6 ! 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 ) {