feat(kol): return to source page after generation and poll while busy

- Add a ?source=workspace|templates query param when opening the
  generate page so the back button and the post-submit redirect return
  to the originating page; invalidate articles + workspace caches on
  success so the new article shows up immediately.
- Poll recent articles every 10s in WorkspaceView and TemplatesView
  while any article is in queued/pending/generating/running, and stop
  the timer once nothing is active.
- Mark the new article as generating inside the generation Submit
  transaction so the client sees the right status as soon as the task
  is queued.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 17:17:17 +08:00
parent fe4a4ffeaa
commit 6e77f72c53
5 changed files with 105 additions and 13 deletions
+32 -6
View File
@@ -2,7 +2,7 @@
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import { LeftOutlined } from "@ant-design/icons-vue";
import { kolGenerateApi } from "@/lib/api";
@@ -14,12 +14,32 @@ import {
isKolVariableValueEmpty,
normalizeKolVariableDefinition,
} from "@/lib/kol-placeholders";
import { formatError } from "@/lib/errors";
const route = useRoute();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useI18n();
const subscriptionPromptId = computed(() => String(route.params.subscriptionPromptId));
const sourcePage = computed<"workspace" | "templates" | null>(() => {
if (route.query.source === "workspace") {
return "workspace";
}
if (route.query.source === "templates") {
return "templates";
}
return null;
});
function resolveReturnRoute():
| { name: "workspace" }
| { name: "articles-templates" } {
if (sourcePage.value === "workspace") {
return { name: "workspace" };
}
return { name: "articles-templates" };
}
const schemaQuery = useQuery({
queryKey: ["kol", "subscription-prompt", "schema", subscriptionPromptId],
@@ -64,17 +84,23 @@ const generateMutation = useMutation({
enable_web_search: allowWebSearch.value ? enableWebSearch.value : undefined,
knowledge_group_ids: allowUserKnowledge.value ? selectedKnowledgeGroupIds.value : undefined,
}),
onSuccess: (data) => {
message.success(t("common.copySuccess")); // Or a generic success message
void router.push({ name: "article-editor", params: { id: String(data.article_id) } });
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
]);
message.info(t("templates.wizard.messages.queued"));
await router.replace(resolveReturnRoute());
},
onError: (error: any) => {
message.error(error.message || t("common.noData"));
message.error(formatError(error) || t("common.noData"));
},
});
function handleBack() {
router.back();
void router.push(resolveReturnRoute());
}
function handleSubmit() {
@@ -77,7 +77,11 @@ function handleGenerate(promptId: number) {
(sp) => sp.prompt_id === promptId && sp.package_id === packageId.value
);
if (subPrompt) {
void router.push({ name: "kol-generate", params: { subscriptionPromptId: String(subPrompt.id) } });
void router.push({
name: "kol-generate",
params: { subscriptionPromptId: String(subPrompt.id) },
query: { source: "templates" },
});
}
}
+12 -4
View File
@@ -189,6 +189,10 @@ const displayedArticleTotal = computed(() =>
let articlePollingTimer: number | null = null;
function hasActiveGenerationStatus(status?: string | null): boolean {
return status === "queued" || status === "pending" || status === "generating" || status === "running";
}
const deleteMutation = useMutation({
mutationFn: (articleId: number) => articlesApi.remove(articleId),
onSuccess: async () => {
@@ -324,7 +328,11 @@ function startTemplate(template: TemplateListItem): void {
function openRefinedTemplate(card: KolWorkspaceCard): void {
pickerOpen.value = false;
pickerMode.value = null;
void router.push({ name: "kol-generate", params: { subscriptionPromptId: String(card.subscription_prompt_id) } });
void router.push({
name: "kol-generate",
params: { subscriptionPromptId: String(card.subscription_prompt_id) },
query: { source: "templates" },
});
}
async function openEditor(article: ArticleListItem): Promise<void> {
@@ -362,7 +370,7 @@ watch(
() => displayedArticles.value,
(items: ArticleListItem[]) => {
const hasGeneratingArticles = items.some((item: ArticleListItem) =>
item.generate_status === "generating" || item.generate_status === "running",
hasActiveGenerationStatus(item.generate_status),
);
if (hasGeneratingArticles) {
@@ -390,8 +398,8 @@ function startArticlePolling(): void {
}
articlePollingTimer = window.setInterval(() => {
void articleListQuery.refetch();
}, 3000);
void Promise.all([articleListQuery.refetch(), recentArticlesQuery.refetch()]);
}, 10000);
}
function stopArticlePolling(): void {
+53 -2
View File
@@ -16,7 +16,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import type { KolWorkspaceCard, RecentArticle, TemplateCard } from "@geo/shared-types";
import { message } from "ant-design-vue";
import type { TableColumnsType } from "ant-design-vue";
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
@@ -38,6 +38,7 @@ const selectedArticleId = ref<number | null>(null);
const articleDrawerOpen = ref(false);
const selectedPublishArticleId = ref<number | null>(null);
const publishModalOpen = ref(false);
let recentArticlesPollingTimer: number | null = null;
const overviewQuery = useQuery({
queryKey: ["workspace", "overview"],
@@ -56,6 +57,10 @@ const kolCardsQuery = useQuery({
queryFn: () => workspaceApi.kolCards(),
});
function hasActiveGenerationStatus(status?: string | null): boolean {
return status === "queued" || status === "pending" || status === "generating" || status === "running";
}
function toTimeValue(value?: string | null): number {
const parsed = Date.parse(value ?? "");
return Number.isNaN(parsed) ? 0 : parsed;
@@ -163,6 +168,14 @@ function openTemplate(template: TemplateCard): void {
});
}
function openRefinedTemplate(card: KolWorkspaceCard): void {
void router.push({
name: "kol-generate",
params: { subscriptionPromptId: String(card.subscription_prompt_id) },
query: { source: "workspace" },
});
}
function openArticle(articleId: number): void {
selectedArticleId.value = articleId;
articleDrawerOpen.value = true;
@@ -223,6 +236,44 @@ function refreshDashboard(): void {
kolCardsQuery.refetch(),
]);
}
watch(
() => recentArticlesQuery.data.value ?? [],
(items: RecentArticle[]) => {
const hasGeneratingArticles = items.some((item) => hasActiveGenerationStatus(item.generate_status));
if (hasGeneratingArticles) {
startRecentArticlesPolling();
return;
}
stopRecentArticlesPolling();
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRecentArticlesPolling();
});
function startRecentArticlesPolling(): void {
if (recentArticlesPollingTimer !== null) {
return;
}
recentArticlesPollingTimer = window.setInterval(() => {
void recentArticlesQuery.refetch();
}, 10000);
}
function stopRecentArticlesPolling(): void {
if (recentArticlesPollingTimer === null) {
return;
}
window.clearInterval(recentArticlesPollingTimer);
recentArticlesPollingTimer = null;
}
</script>
<template>
@@ -289,7 +340,7 @@ function refreshDashboard(): void {
v-for="card in sortedKolCards"
:key="card.subscription_prompt_id"
class="kol-card"
@click="router.push(`/kol/generate/${card.subscription_prompt_id}`)"
@click="openRefinedTemplate(card)"
>
<div class="kol-card-cover" :style="card.package_cover ? { backgroundImage: `url(${card.package_cover})` } : {}">
<div v-if="!card.package_cover" class="kol-card-cover-fallback">
@@ -257,6 +257,9 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
if err != nil {
return nil, response.ErrInternal(50103, "kol_generation_article_create_failed", err.Error())
}
if err := articleTx.UpdateArticleGenerateStatus(ctx, articleID, actor.TenantID, "generating"); err != nil {
return nil, response.ErrInternal(50112, "kol_generation_article_state_update_failed", err.Error())
}
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
return nil, response.ErrInternal(50104, "kol_generation_reservation_update_failed", err.Error())