Files
geo/apps/admin-web/src/views/KolGenerateView.vue
T
root 6e77f72c53 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>
2026-04-18 17:17:17 +08:00

399 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
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";
import KolDynamicForm from "@/components/kol/KolDynamicForm.vue";
import KnowledgeGroupSelect from "@/components/KnowledgeGroupSelect.vue";
import { parseKolCardConfig } from "@/lib/kol-card-config";
import {
getKolVariableInitialValue,
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],
queryFn: () => kolGenerateApi.schema(subscriptionPromptId.value),
enabled: computed(() => !!subscriptionPromptId.value),
});
const values = ref<Record<string, any>>({});
const enableWebSearch = ref(false);
const selectedKnowledgeGroupIds = ref<number[]>([]);
const schema = computed(() => schemaQuery.data.value);
const cardConfig = computed(() => parseKolCardConfig(schema.value?.card_config_json));
const allowWebSearch = computed(() => cardConfig.value.allow_web_search);
const allowUserKnowledge = computed(() => cardConfig.value.allow_user_knowledge);
function fieldKey(key?: string | null, id?: string): string {
return key?.trim() || id || "";
}
// Initialize values from schema
watch(
() => schemaQuery.data.value,
(schema) => {
if (schema?.schema_json?.variables) {
const newValues: Record<string, any> = {};
schema.schema_json.variables.forEach((v) => {
newValues[fieldKey(v.key, v.id)] = getKolVariableInitialValue(normalizeKolVariableDefinition(v));
});
values.value = newValues;
enableWebSearch.value = false;
selectedKnowledgeGroupIds.value = [];
}
},
{ immediate: true }
);
const generateMutation = useMutation({
mutationFn: (variables: Record<string, any>) =>
kolGenerateApi.submit(subscriptionPromptId.value, {
variables,
enable_web_search: allowWebSearch.value ? enableWebSearch.value : undefined,
knowledge_group_ids: allowUserKnowledge.value ? selectedKnowledgeGroupIds.value : undefined,
}),
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(formatError(error) || t("common.noData"));
},
});
function handleBack() {
void router.push(resolveReturnRoute());
}
function handleSubmit() {
const schemaValue = schemaQuery.data.value;
if (!schemaValue?.schema_json?.variables) return;
const missingLabels: string[] = [];
schemaValue.schema_json.variables.forEach((v) => {
const normalizedVariable = normalizeKolVariableDefinition(v);
if (normalizedVariable.required && isKolVariableValueEmpty(normalizedVariable, values.value[fieldKey(v.key, v.id)])) {
missingLabels.push(v.label);
}
});
if (missingLabels.length > 0) {
message.warning(`${t("kol.generate.fillVariables")}: ${missingLabels.join(", ")}`);
return;
}
generateMutation.mutate(values.value);
}
</script>
<template>
<div class="wizard-page">
<div v-if="schemaQuery.isPending.value" class="loading-state">
<a-spin size="large" />
</div>
<template v-else-if="schema">
<header class="wizard-page__header">
<div class="header-left">
<a-button type="text" shape="circle" @click="handleBack">
<template #icon><LeftOutlined /></template>
</a-button>
<div class="header-title-box">
<h2 class="header-title">
{{ schema.package_name }}
<span class="divider">/</span>
{{ schema.prompt_name }}
<a-tag v-if="schema.platform_hint" color="cyan" class="platform-badge">
{{ schema.platform_hint }}
</a-tag>
</h2>
<p class="header-eyebrow">{{ t("kol.generate.fillVariables") }}</p>
</div>
</div>
</header>
<main class="wizard-page__main">
<section class="wizard-section">
<div class="wizard-card">
<div class="wizard-card__header" style="margin-bottom: 24px;">
<div>
<h3>{{ t("kol.generate.fillVariables") }}</h3>
<p>根据设定提供相关信息</p>
</div>
</div>
<div v-if="allowWebSearch || allowUserKnowledge" class="capability-card">
<div v-if="allowWebSearch" class="capability-row">
<div class="capability-copy">
<div class="capability-label">联网搜索</div>
<div class="capability-hint">按需启用生成时可参考实时网页信息</div>
</div>
<a-switch
v-model:checked="enableWebSearch"
:disabled="generateMutation.isPending.value"
/>
</div>
<div v-if="allowUserKnowledge" class="capability-knowledge">
<label class="capability-label">知识库引用</label>
<KnowledgeGroupSelect
v-model="selectedKnowledgeGroupIds"
:disabled="generateMutation.isPending.value"
placeholder="可选,选择你自己的知识库分组辅助生成"
/>
</div>
</div>
<KolDynamicForm
v-model="values"
:variables="schema.schema_json.variables"
class="form-body"
/>
</div>
</section>
</main>
<footer class="wizard-page__footer">
<div></div>
<div class="footer-actions">
<a-button
type="primary"
:loading="generateMutation.isPending.value"
@click="handleSubmit"
>
{{ t("kol.generate.submit") }}
</a-button>
</div>
</footer>
</template>
</div>
</template>
<style scoped>
.wizard-page {
display: flex;
flex-direction: column;
min-height: calc(100vh - 120px);
background: #fff;
}
.loading-state {
display: flex;
justify-content: center;
padding: 100px;
}
.wizard-page__header {
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
border-bottom: 1px solid #edf2f7;
background: #fff;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title-box {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title {
margin: 0;
color: #111827;
font-size: 20px;
font-weight: 800;
display: flex;
align-items: center;
}
.divider {
margin: 0 8px;
color: #d9d9d9;
font-weight: 400;
}
.platform-badge {
margin-left: 8px;
}
.header-eyebrow {
margin: 0;
color: #6d7c94;
font-size: 13px;
}
.wizard-page__main {
display: flex;
flex-direction: column;
margin: 0 auto;
width: 100%;
max-width: 860px;
padding: 24px 32px 64px;
}
.wizard-section {
display: flex;
flex-direction: column;
}
.wizard-card {
padding: 32px 0;
}
.wizard-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.wizard-card__header h3 {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 800;
display: flex;
align-items: center;
}
.wizard-card__header h3::before {
content: "";
display: inline-block;
width: 4px;
height: 16px;
border-radius: 2px;
background: #111827;
margin-right: 12px;
}
.wizard-card__header p {
margin: 6px 0 0 16px;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.capability-card {
margin-bottom: 32px;
padding: 20px;
border: 1px solid #e6edf5;
border-radius: 12px;
background: #fafcff;
}
.capability-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.capability-knowledge {
margin-top: 20px;
}
.capability-copy {
min-width: 0;
}
.capability-label {
display: block;
margin-bottom: 8px;
color: #111827;
font-size: 14px;
font-weight: 600;
}
.capability-hint {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.wizard-page__footer {
position: sticky;
bottom: 0;
z-index: 100;
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid rgba(20, 44, 88, 0.08);
backdrop-filter: blur(14px);
background: rgba(255, 255, 255, 0.88);
}
.footer-actions {
display: flex;
gap: 12px;
}
@media (max-width: 768px) {
.wizard-page__main {
padding: 16px;
}
.wizard-card {
padding: 18px 0;
}
.wizard-page__footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.footer-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>