公众号助手bug修复
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user