feat(article): support regenerating failed articles

This commit is contained in:
2026-05-07 10:55:44 +08:00
parent ff2bb77cdd
commit 7d82c5c193
21 changed files with 659 additions and 23 deletions
@@ -4,6 +4,7 @@ import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
ReloadOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
@@ -22,6 +23,11 @@ withDefaults(
showCopy?: boolean
canCopy?: boolean
copyDisabledTitle?: string
showRegenerate?: boolean
canRegenerate?: boolean
regenerateDisabledTitle?: string
regenerateConfirmTitle?: string
regenerateLoading?: boolean
showDelete?: boolean
canDelete?: boolean
deleteDisabledTitle?: string
@@ -41,6 +47,11 @@ withDefaults(
showCopy: false,
canCopy: true,
copyDisabledTitle: '',
showRegenerate: false,
canRegenerate: true,
regenerateDisabledTitle: '',
regenerateConfirmTitle: '',
regenerateLoading: false,
showDelete: true,
canDelete: true,
deleteDisabledTitle: '',
@@ -54,6 +65,7 @@ const emit = defineEmits<{
edit: []
preview: []
copy: []
regenerate: []
delete: []
}>()
@@ -135,6 +147,43 @@ function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: st
</span>
</a-tooltip>
<a-tooltip
v-if="showRegenerate && !regenerateConfirmTitle"
:title="resolveTitle(canRegenerate, t('common.regenerate'), regenerateDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-regenerate"
:disabled="!canRegenerate"
:loading="regenerateLoading"
@click="emit('regenerate')"
>
<ReloadOutlined />
</a-button>
</span>
</a-tooltip>
<a-popconfirm
v-else-if="showRegenerate"
:title="regenerateConfirmTitle"
:disabled="!canRegenerate"
@confirm="emit('regenerate')"
>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-regenerate"
:disabled="!canRegenerate"
:loading="regenerateLoading"
>
<ReloadOutlined />
</a-button>
</a-popconfirm>
<a-tooltip
v-if="showDelete && !deleteConfirmTitle"
:title="resolveTitle(canDelete, t('common.delete'), deleteDisabledTitle)"
@@ -195,6 +244,11 @@ function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: st
background: #e6f4ff;
}
.action-regenerate.ant-btn:hover:not(:disabled) {
color: #fa8c16;
background: #fff7e6;
}
.action-delete.ant-btn:hover:not(:disabled) {
color: #ff4d4f;
background: #fff2f0;
@@ -15,6 +15,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, promptRulesApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { formatPublishPlatformList } from '@/lib/publish-platforms'
@@ -110,6 +111,8 @@ const deleteMutation = useMutation({
onError: (error) => message.error(formatError(error)),
})
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
{ title: t('custom.article.titleColumn'), dataIndex: 'title', key: 'title', width: 340 },
{
@@ -353,13 +356,16 @@ onBeforeUnmount(() => {
</template>
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openDetail(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="deleteMutation.mutate(record.id)"
/>
</template>
@@ -51,6 +51,10 @@ const enUS = {
upgrade: 'Upgrade',
copy: 'Copy',
copySuccess: 'Content copied',
regenerate: 'Regenerate',
regenerateConfirm: 'Regenerate this article?',
regenerateSuccess: 'Regeneration queued',
regenerateError: 'Regeneration failed, please try again',
yes: 'Yes',
no: 'No',
imageUploadProgress: {
@@ -51,6 +51,10 @@ const zhCN = {
upgrade: "去升级",
copy: "复制",
copySuccess: "内容已复制",
regenerate: "重新生成",
regenerateConfirm: "确认重新生成这篇文章吗?",
regenerateSuccess: "重新生成已提交",
regenerateError: "重新生成失败,请稍后重试",
yes: "是",
no: "否",
imageUploadProgress: {
+14 -5
View File
@@ -13,16 +13,16 @@ import type {
BrandRequest,
Competitor,
CompetitorRequest,
CreateArticleRequest,
CreateKolPackageRequest,
CreateKolPromptRequest,
CreateAckRequest,
ComplianceAckRecord,
ComplianceCheckArticleRequest,
ComplianceCheckPlainRequest,
ComplianceCheckResult,
ComplianceManualReview,
ComplianceRuntimeStatus,
CreateAckRequest,
CreateArticleRequest,
CreateKolPackageRequest,
CreateKolPromptRequest,
CreatePluginSessionRequest,
CreatePluginSessionResponse,
CreatePublishBatchRequest,
@@ -92,6 +92,7 @@ import type {
QuotaSummary,
RecentArticle,
RefreshResponse,
RegenerateArticleResponse,
RegisterPluginInstallationRequest,
RegisterPluginInstallationResponse,
ScheduleTask,
@@ -626,6 +627,12 @@ export const articlesApi = {
payload,
)
},
regenerate(id: number) {
return apiClient.post<RegenerateArticleResponse, null>(
`/api/tenant/articles/${id}/regenerate`,
null,
)
},
detail(id: number) {
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`)
},
@@ -1115,7 +1122,9 @@ export const complianceApi = {
)
},
latestArticleCheck(articleId: number) {
return apiClient.get<ComplianceCheckResult>(`/api/tenant/articles/${articleId}/compliance/latest`)
return apiClient.get<ComplianceCheckResult>(
`/api/tenant/articles/${articleId}/compliance/latest`,
)
},
createAck(recordId: number, payload: CreateAckRequest) {
return apiClient.post<ComplianceAckRecord, CreateAckRequest>(
+22 -1
View File
@@ -6,9 +6,26 @@ export interface ArticleActionState {
showPreview: boolean
showCopy: boolean
showDelete: boolean
showRegenerate: boolean
}
export function resolveArticleActionState(generateStatus?: string | null): ArticleActionState {
export interface ArticleActionSource {
generate_status?: string | null
source_type?: string | null
}
const regeneratableSourceTypes = new Set(['template', 'custom_generation', 'imitation', 'kol'])
export function resolveArticleActionState(
articleOrGenerateStatus?: ArticleActionSource | string | null,
): ArticleActionState {
const generateStatus =
typeof articleOrGenerateStatus === 'string'
? articleOrGenerateStatus
: articleOrGenerateStatus?.generate_status
const sourceType =
typeof articleOrGenerateStatus === 'string' ? undefined : articleOrGenerateStatus?.source_type
if (generateStatus === 'completed') {
return {
showPublish: true,
@@ -16,6 +33,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: true,
showCopy: true,
showDelete: true,
showRegenerate: false,
}
}
@@ -26,6 +44,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: false,
showCopy: false,
showDelete: true,
showRegenerate: false,
}
}
@@ -36,6 +55,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: false,
showCopy: false,
showDelete: true,
showRegenerate: sourceType ? regeneratableSourceTypes.has(sourceType) : false,
}
}
@@ -45,6 +65,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: true,
showCopy: false,
showDelete: true,
showRegenerate: false,
}
}
@@ -0,0 +1,43 @@
import type { ArticleListItem, RecentArticle } from '@geo/shared-types'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { articlesApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
type RegeneratableArticle = Pick<ArticleListItem | RecentArticle, 'id'>
export function useArticleRegenerateAction() {
const queryClient = useQueryClient()
const { t } = useI18n()
const regeneratingArticleId = ref<number | null>(null)
const regenerateMutation = useMutation({
mutationFn: (article: RegeneratableArticle) => articlesApi.regenerate(article.id),
onSuccess: async () => {
message.success(t('common.regenerateSuccess'))
regeneratingArticleId.value = null
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
},
onError: (error) => {
regeneratingArticleId.value = null
message.error(formatError(error) || t('common.regenerateError'))
},
})
async function handleRegenerate(article: RegeneratableArticle): Promise<void> {
regeneratingArticleId.value = article.id
await regenerateMutation.mutateAsync(article)
}
return {
regeneratingArticleId,
regenerateMutation,
handleRegenerate,
}
}
+7 -1
View File
@@ -16,6 +16,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { getPublishStatusMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
@@ -101,6 +102,8 @@ const createMutation = useMutation({
},
})
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
const publishStatusOptions = computed(() => [
{ label: getPublishStatusMeta('unpublished').label, value: 'unpublished' },
{ label: getPublishStatusMeta('publishing').label, value: 'publishing' },
@@ -309,13 +312,16 @@ async function copyArticle(articleId: number): Promise<void> {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('freeCreate.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openPreview(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+7 -1
View File
@@ -17,6 +17,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
@@ -99,6 +100,8 @@ const deleteMutation = useMutation({
},
})
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
const generateStatusOptions = computed(() => [
{ label: getGenerateStatusMeta('generating').label, value: 'generating' },
{ label: getGenerateStatusMeta('completed').label, value: 'completed' },
@@ -435,13 +438,16 @@ async function copyArticle(articleId: number): Promise<void> {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('imitation.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openPreview(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+7 -1
View File
@@ -34,6 +34,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, templatesApi, workspaceApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import {
formatDateTime,
getGenerateStatusMeta,
@@ -216,6 +217,8 @@ const deleteMutation = useMutation({
},
})
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
const templateFilterOptions = computed(() => {
return [
{ label: t('templates.sections.generalTemplates'), value: 'source:template' },
@@ -599,13 +602,16 @@ function refreshRecords(): void {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openPreview(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+7 -1
View File
@@ -28,6 +28,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, tenantAccountsApi, workspaceApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { formatDateTime, getTemplateMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { isMediaPublishAccount } from '@/lib/publish-platforms'
@@ -157,6 +158,8 @@ const deleteMutation = useMutation({
},
})
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
// Helper for the gradient classes based on index/type
function getTemplateCardClass(idx: number): string {
const classes = ['card-yellow', 'card-blue-light', 'card-blue-deep', 'card-grey']
@@ -451,13 +454,16 @@ function stopRecentArticlesPolling(): void {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record.id)"
@edit="openEditor(record)"
@preview="openArticle(record.id)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>