feat: Enhance Kol Generation Service with web search and knowledge group support
- Added `EnableWebSearch` and `KnowledgeGroupIDs` fields to `KolGenerationSubmitRequest`. - Updated `Submit` method in `KolGenerationService` to handle new request fields. - Integrated web search and knowledge group validation based on card configuration. - Introduced caching mechanisms in `KolPackageService`, `KolPromptService`, and `KolSubscriptionAdminService` to improve performance. - Implemented knowledge context resolution in `KolGenerationWorker` to enrich prompts with relevant knowledge snippets. - Added utility functions for handling card configuration in both backend and frontend. - Created tests for knowledge extraction and rendering to ensure accuracy and reliability.
This commit is contained in:
@@ -7,6 +7,8 @@ import { message } from "ant-design-vue";
|
||||
import { ArrowLeftOutlined, ThunderboltOutlined } 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";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -21,6 +23,13 @@ const schemaQuery = useQuery({
|
||||
});
|
||||
|
||||
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 || "";
|
||||
@@ -36,6 +45,8 @@ watch(
|
||||
newValues[fieldKey(v.key, v.id)] = v.type === "checkbox" ? [] : undefined;
|
||||
});
|
||||
values.value = newValues;
|
||||
enableWebSearch.value = false;
|
||||
selectedKnowledgeGroupIds.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -43,7 +54,11 @@ watch(
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: (variables: Record<string, any>) =>
|
||||
kolGenerateApi.submit(subscriptionPromptId.value, { variables }),
|
||||
kolGenerateApi.submit(subscriptionPromptId.value, {
|
||||
variables,
|
||||
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) } });
|
||||
@@ -75,8 +90,6 @@ function handleSubmit() {
|
||||
|
||||
generateMutation.mutate(values.value);
|
||||
}
|
||||
|
||||
const schema = computed(() => schemaQuery.data.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,6 +125,29 @@ const schema = computed(() => schemaQuery.data.value);
|
||||
{{ t("kol.generate.fillVariables") }}
|
||||
</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 class="capability-hint">只会使用你当前租户下可访问的知识库分组。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KolDynamicForm
|
||||
v-model="values"
|
||||
:variables="schema.schema_json.variables"
|
||||
@@ -207,6 +243,43 @@ const schema = computed(() => schemaQuery.data.value);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.capability-card {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
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: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const editingPackage = ref<KolPackageSummary | null>(null);
|
||||
const promptModalOpen = ref(false);
|
||||
const editingPromptId = ref<number | null>(null);
|
||||
const togglingPromptId = ref<number | null>(null);
|
||||
const deletingPromptId = ref<number | null>(null);
|
||||
|
||||
const { data: packages, isPending: packagesPending } = useQuery({
|
||||
queryKey: ["kol", "packages"],
|
||||
@@ -124,6 +125,28 @@ const togglePromptStatusMutation = useMutation({
|
||||
},
|
||||
});
|
||||
|
||||
const deletePromptMutation = useMutation({
|
||||
mutationFn: (id: number) => kolManageApi.deletePrompt(id),
|
||||
onMutate: (id) => {
|
||||
deletingPromptId.value = id;
|
||||
},
|
||||
onSuccess: (_, id) => {
|
||||
message.success(t("common.delete") + "成功");
|
||||
void queryClient.invalidateQueries({ queryKey: ["kol", "packages"] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["kol", "packages", selectedPackageId.value, "prompts"],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["kol", "prompt", id] });
|
||||
if (editingPromptId.value === id) {
|
||||
editingPromptId.value = null;
|
||||
}
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
onSettled: () => {
|
||||
deletingPromptId.value = null;
|
||||
},
|
||||
});
|
||||
|
||||
const packageInitialValue = computed<Partial<CreateKolPackageRequest> | undefined>(() => {
|
||||
if (!editingPackage.value) return undefined;
|
||||
return {
|
||||
@@ -175,6 +198,14 @@ function handleCloseEditor() {
|
||||
editingPromptId.value = null;
|
||||
}
|
||||
|
||||
function handleDeletePrompt(id: number) {
|
||||
Modal.confirm({
|
||||
title: t("common.delete") + "?",
|
||||
content: "确定要删除这个提示词吗?",
|
||||
onOk: () => deletePromptMutation.mutate(id),
|
||||
});
|
||||
}
|
||||
|
||||
function promptStatusLabel(status: string) {
|
||||
if (status === "active") return t("kol.manage.status.active");
|
||||
if (status === "archived") return t("kol.manage.status.archived");
|
||||
@@ -268,7 +299,7 @@ function promptStatusLabel(status: string) {
|
||||
{ title: t('common.title'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('tracking.columns.platform'), dataIndex: 'platform_hint', key: 'platform_hint' },
|
||||
{ title: t('common.status'), dataIndex: 'status', key: 'status' },
|
||||
{ title: t('common.actions'), key: 'actions', align: 'right', width: 112 },
|
||||
{ title: t('common.actions'), key: 'actions', align: 'right', width: 156 },
|
||||
]"
|
||||
:data-source="prompts"
|
||||
:loading="promptsPending"
|
||||
@@ -318,6 +349,18 @@ function promptStatusLabel(status: string) {
|
||||
<PlayCircleOutlined v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('common.delete')">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn action-delete"
|
||||
:loading="deletePromptMutation.isPending.value && deletingPromptId === record.id"
|
||||
@click="handleDeletePrompt(record.id)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -467,4 +510,9 @@ function promptStatusLabel(status: string) {
|
||||
color: #fa8c16;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.action-delete.ant-btn:hover:not(:disabled) {
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PlusOutlined,
|
||||
import {
|
||||
PlusOutlined,
|
||||
AppstoreOutlined,
|
||||
BlockOutlined,
|
||||
ExperimentOutlined,
|
||||
FileTextOutlined,
|
||||
NodeIndexOutlined,
|
||||
RocketOutlined,
|
||||
ThunderboltOutlined,
|
||||
TrophyOutlined,
|
||||
ArrowRightOutlined
|
||||
ArrowRightOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { ArticleListItem, ArticleListParams, TemplateListItem } from "@geo/shared-types";
|
||||
import type {
|
||||
ArticleListItem,
|
||||
ArticleListParams,
|
||||
KolWorkspaceCard,
|
||||
RecentArticle,
|
||||
TemplateListItem,
|
||||
} from "@geo/shared-types";
|
||||
import type { TableColumnsType } from "ant-design-vue";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||
@@ -24,7 +32,7 @@ import ArticlePublishStatus from "@/components/ArticlePublishStatus.vue";
|
||||
import ArticleSourceMeta from "@/components/ArticleSourceMeta.vue";
|
||||
import ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
|
||||
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||
import { articlesApi, templatesApi } from "@/lib/api";
|
||||
import { articlesApi, templatesApi, workspaceApi } from "@/lib/api";
|
||||
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
|
||||
import { formatError } from "@/lib/errors";
|
||||
import {
|
||||
@@ -38,6 +46,7 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const pickerOpen = ref(false);
|
||||
const pickerMode = ref<"general" | "refined" | null>(null);
|
||||
const publishModalOpen = ref(false);
|
||||
const selectedPublishArticleId = ref<number | null>(null);
|
||||
const articleDrawerOpen = ref(false);
|
||||
@@ -47,7 +56,7 @@ const pageSize = ref(10);
|
||||
const draftGenerationRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||
|
||||
const draftFilters = reactive<{
|
||||
template_id?: number;
|
||||
template_filter?: string;
|
||||
publish_status?: string;
|
||||
generate_status?: string;
|
||||
keyword?: string;
|
||||
@@ -58,7 +67,7 @@ const draftFilters = reactive<{
|
||||
});
|
||||
|
||||
const appliedFilters = reactive<{
|
||||
template_id?: number;
|
||||
template_filter?: string;
|
||||
publish_status?: string;
|
||||
generate_status?: string;
|
||||
keyword?: string;
|
||||
@@ -73,15 +82,32 @@ const templateListQuery = useQuery({
|
||||
queryFn: () => templatesApi.list(),
|
||||
});
|
||||
|
||||
const kolCardsQuery = useQuery({
|
||||
queryKey: ["workspace", "kol-cards", "templates"],
|
||||
queryFn: () => workspaceApi.kolCards(),
|
||||
});
|
||||
|
||||
const recentArticlesQuery = useQuery({
|
||||
queryKey: ["workspace", "recent-articles", "templates"],
|
||||
queryFn: () => workspaceApi.recentArticles(),
|
||||
});
|
||||
|
||||
const articleParams = computed<ArticleListParams>(() => {
|
||||
const params: ArticleListParams = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
source_type: "template",
|
||||
source_type: "template,kol",
|
||||
};
|
||||
|
||||
if (appliedFilters.template_id) {
|
||||
params.template_id = appliedFilters.template_id;
|
||||
const templateFilter = appliedFilters.template_filter;
|
||||
if (templateFilter?.startsWith("source:")) {
|
||||
params.source_type = templateFilter.replace("source:", "");
|
||||
} else if (templateFilter?.startsWith("template:")) {
|
||||
params.source_type = "template";
|
||||
params.template_id = Number(templateFilter.replace("template:", ""));
|
||||
} else if (templateFilter?.startsWith("kol:")) {
|
||||
params.source_type = "kol";
|
||||
params.kol_prompt_id = Number(templateFilter.replace("kol:", ""));
|
||||
}
|
||||
if (appliedFilters.publish_status) {
|
||||
params.publish_status = appliedFilters.publish_status;
|
||||
@@ -107,6 +133,59 @@ const articleListQuery = useQuery({
|
||||
queryFn: () => articlesApi.list(articleParams.value),
|
||||
});
|
||||
|
||||
const hasActiveRecordFilters = computed(() =>
|
||||
Boolean(
|
||||
appliedFilters.template_filter ||
|
||||
appliedFilters.publish_status ||
|
||||
appliedFilters.generate_status ||
|
||||
appliedFilters.keyword?.trim() ||
|
||||
appliedFilters.created_from ||
|
||||
appliedFilters.created_to,
|
||||
),
|
||||
);
|
||||
|
||||
const recentTemplateArticles = computed<ArticleListItem[]>(() =>
|
||||
(recentArticlesQuery.data.value || [])
|
||||
.filter((article: RecentArticle) => article.source_type === "template" || article.source_type === "kol")
|
||||
.map((article: RecentArticle) => ({
|
||||
id: article.id,
|
||||
source_type: article.source_type,
|
||||
template_id: null,
|
||||
kol_prompt_id: null,
|
||||
template_name:
|
||||
article.template_name ||
|
||||
(article.source_type === "kol"
|
||||
? t("templates.sections.refinedTemplates")
|
||||
: t("templates.sections.generalTemplates")),
|
||||
prompt_rule_id: null,
|
||||
prompt_rule_name: null,
|
||||
generation_mode: article.generation_mode,
|
||||
platforms: [],
|
||||
generate_status: article.generate_status,
|
||||
publish_status: article.publish_status,
|
||||
title: article.title,
|
||||
generation_error_message: null,
|
||||
word_count: article.word_count,
|
||||
source_label: article.source_label,
|
||||
created_at: article.created_at,
|
||||
})),
|
||||
);
|
||||
|
||||
const shouldUseRecentFallback = computed(
|
||||
() =>
|
||||
!hasActiveRecordFilters.value &&
|
||||
(articleListQuery.data.value?.total ?? 0) === 0 &&
|
||||
recentTemplateArticles.value.length > 0,
|
||||
);
|
||||
|
||||
const displayedArticles = computed<ArticleListItem[]>(() =>
|
||||
shouldUseRecentFallback.value ? recentTemplateArticles.value : articleListQuery.data.value?.items || [],
|
||||
);
|
||||
|
||||
const displayedArticleTotal = computed(() =>
|
||||
shouldUseRecentFallback.value ? recentTemplateArticles.value.length : articleListQuery.data.value?.total || 0,
|
||||
);
|
||||
|
||||
let articlePollingTimer: number | null = null;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -123,12 +202,12 @@ const deleteMutation = useMutation({
|
||||
},
|
||||
});
|
||||
|
||||
const templateOptions = computed(() =>
|
||||
(templateListQuery.data.value || []).map((template: TemplateListItem) => ({
|
||||
label: template.template_name,
|
||||
value: template.id,
|
||||
})),
|
||||
);
|
||||
const templateFilterOptions = computed(() => {
|
||||
return [
|
||||
{ label: t("templates.sections.generalTemplates"), value: "source:template" },
|
||||
{ label: t("templates.sections.refinedTemplates"), value: "source:kol" },
|
||||
];
|
||||
});
|
||||
|
||||
const generateStatusOptions = computed(() => [
|
||||
{ label: getGenerateStatusMeta("draft").label, value: "draft" },
|
||||
@@ -188,7 +267,7 @@ const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||
|
||||
function applyFilters(): void {
|
||||
page.value = 1;
|
||||
appliedFilters.template_id = draftFilters.template_id;
|
||||
appliedFilters.template_filter = draftFilters.template_filter;
|
||||
appliedFilters.publish_status = draftFilters.publish_status;
|
||||
appliedFilters.generate_status = draftFilters.generate_status;
|
||||
appliedFilters.keyword = draftFilters.keyword?.trim() || "";
|
||||
@@ -218,7 +297,7 @@ function resolveTemplateIcon(template: TemplateListItem): unknown {
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
draftFilters.template_id = undefined;
|
||||
draftFilters.template_filter = undefined;
|
||||
draftFilters.publish_status = undefined;
|
||||
draftFilters.generate_status = undefined;
|
||||
draftFilters.keyword = "";
|
||||
@@ -228,26 +307,44 @@ function resetFilters(): void {
|
||||
|
||||
function openTemplatePicker(): void {
|
||||
pickerOpen.value = true;
|
||||
pickerMode.value = "general";
|
||||
}
|
||||
|
||||
function choosePickerMode(mode: "general" | "refined"): void {
|
||||
pickerMode.value = mode;
|
||||
}
|
||||
|
||||
function startTemplate(template: TemplateListItem): void {
|
||||
pickerOpen.value = false;
|
||||
pickerMode.value = null;
|
||||
void router.push({ path: "/articles/wizard", query: { template_id: String(template.id) } });
|
||||
}
|
||||
|
||||
function openEditor(article: ArticleListItem): void {
|
||||
if ((article.generate_status === "draft" || article.generate_status === "failed") && article.template_id) {
|
||||
void router.push({
|
||||
name: "article-wizard",
|
||||
query: {
|
||||
template_id: String(article.template_id),
|
||||
article_id: String(article.id),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
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: "article-editor", params: { id: String(article.id) } });
|
||||
async function openEditor(article: ArticleListItem): Promise<void> {
|
||||
try {
|
||||
const detail = await articlesApi.detail(article.id);
|
||||
|
||||
if (detail.generate_status !== "completed" && detail.source_type === "template" && detail.template_id) {
|
||||
await router.push({
|
||||
name: "article-wizard",
|
||||
query: {
|
||||
template_id: String(detail.template_id),
|
||||
article_id: String(detail.id),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({ name: "article-editor", params: { id: String(detail.id) } });
|
||||
} catch (error) {
|
||||
message.error(formatError(error) || t("common.noData"));
|
||||
}
|
||||
}
|
||||
|
||||
function openPublish(article: ArticleListItem): void {
|
||||
@@ -261,7 +358,7 @@ function openPreview(article: ArticleListItem): void {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => articleListQuery.data.value?.items ?? [],
|
||||
() => displayedArticles.value,
|
||||
(items: ArticleListItem[]) => {
|
||||
const hasGeneratingArticles = items.some((item: ArticleListItem) =>
|
||||
item.generate_status === "generating" || item.generate_status === "running",
|
||||
@@ -325,6 +422,10 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
message.error(formatError(error) || t("common.noData"));
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRecords(): void {
|
||||
void Promise.all([articleListQuery.refetch(), recentArticlesQuery.refetch()]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,7 +441,7 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
<template #icon><BlockOutlined /></template>
|
||||
{{ t("templates.actions.batchGenerate") }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openTemplatePicker">
|
||||
<a-button type="primary" class="templates-view__create-btn" @click="openTemplatePicker">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ t("templates.actions.chooseTemplate") }}
|
||||
</a-button>
|
||||
@@ -354,9 +455,9 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
<div class="templates-view__filter-item">
|
||||
<label>{{ t("templates.filters.template") }}:</label>
|
||||
<a-select
|
||||
v-model:value="draftFilters.template_id"
|
||||
v-model:value="draftFilters.template_filter"
|
||||
allow-clear
|
||||
:options="templateOptions"
|
||||
:options="templateFilterOptions"
|
||||
:placeholder="t('common.selectPlease')"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
@@ -417,27 +518,31 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
<section class="templates-view__table-card">
|
||||
<div class="templates-view__table-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("workspace.sections.templates") }}</p>
|
||||
<h3>{{ t("templates.list.count", { count: articleListQuery.data.value?.total ?? 0 }) }}</h3>
|
||||
<p class="eyebrow">{{ t("templates.list.eyebrow") }}</p>
|
||||
<h3>{{ t("templates.list.count", { count: displayedArticleTotal }) }}</h3>
|
||||
</div>
|
||||
<a-button type="link" @click="articleListQuery.refetch()">
|
||||
<a-button type="link" @click="refreshRecords">
|
||||
{{ t("common.refresh") }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="articleColumns"
|
||||
:data-source="articleListQuery.data.value?.items || []"
|
||||
:loading="articleListQuery.isPending.value"
|
||||
:data-source="displayedArticles"
|
||||
:loading="articleListQuery.isPending.value || recentArticlesQuery.isPending.value"
|
||||
row-key="id"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: articleListQuery.data.value?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: handleTableChange,
|
||||
onShowSizeChange: handleTableChange,
|
||||
}"
|
||||
:pagination="
|
||||
shouldUseRecentFallback
|
||||
? false
|
||||
: {
|
||||
current: page,
|
||||
pageSize,
|
||||
total: displayedArticleTotal,
|
||||
showSizeChanger: true,
|
||||
onChange: handleTableChange,
|
||||
onShowSizeChange: handleTableChange,
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #emptyText>{{ t("templates.list.empty") }}</template>
|
||||
|
||||
@@ -488,36 +593,116 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
<a-drawer
|
||||
v-model:open="pickerOpen"
|
||||
:title="t('templates.picker.title')"
|
||||
placement="right"
|
||||
width="960"
|
||||
centered
|
||||
:footer="null"
|
||||
class="templates-view__picker"
|
||||
class="templates-view__drawer"
|
||||
>
|
||||
<div style="text-align: center; margin-bottom: 32px; margin-top: 16px;">
|
||||
<h3 style="font-size: 18px; font-weight: 700; color: #141414; margin: 0;">{{ t('templates.picker.title') }}</h3>
|
||||
<div class="templates-view__drawer-intro">
|
||||
<p>{{ t("route.templates.description") }}</p>
|
||||
</div>
|
||||
<div class="template-cards-container">
|
||||
<article
|
||||
v-for="(template, idx) in templateListQuery.data.value || []"
|
||||
:key="template.id"
|
||||
class="template-card"
|
||||
:class="getTemplateCardClass(idx)"
|
||||
@click="startTemplate(template)"
|
||||
|
||||
<div class="templates-view__drawer-mode-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="templates-view__drawer-mode-card"
|
||||
:class="{ 'is-active': pickerMode === 'general' }"
|
||||
@click="choosePickerMode('general')"
|
||||
>
|
||||
<div class="template-icon-wrapper" :class="getTemplateBgColorClass(idx)">
|
||||
<component :is="resolveTemplateIcon(template)" class="template-icon" />
|
||||
</div>
|
||||
<h4 class="template-title">{{ template.template_name }}</h4>
|
||||
<p class="template-desc">{{ getTemplateMeta(template.template_key).helper }}</p>
|
||||
<div class="template-action">
|
||||
<span>{{ getTemplateMeta(template.template_key).action }}</span>
|
||||
<ArrowRightOutlined class="template-arrow" />
|
||||
</div>
|
||||
</article>
|
||||
<span class="templates-view__drawer-mode-icon templates-view__drawer-mode-icon--general">
|
||||
<AppstoreOutlined />
|
||||
</span>
|
||||
<span class="templates-view__drawer-mode-copy">
|
||||
<strong>{{ t("templates.sections.generalTemplates") }}</strong>
|
||||
<small>{{ t("templates.picker.generalDescription") }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="templates-view__drawer-mode-card"
|
||||
:class="{ 'is-active': pickerMode === 'refined' }"
|
||||
@click="choosePickerMode('refined')"
|
||||
>
|
||||
<span class="templates-view__drawer-mode-icon templates-view__drawer-mode-icon--refined">
|
||||
<ThunderboltOutlined />
|
||||
</span>
|
||||
<span class="templates-view__drawer-mode-copy">
|
||||
<strong>{{ t("templates.sections.refinedTemplates") }}</strong>
|
||||
<small>{{ t("templates.picker.refinedDescription") }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<div v-if="pickerMode === 'general'" class="templates-view__drawer-panel">
|
||||
<div class="templates-view__drawer-panel-head">
|
||||
<p class="eyebrow">{{ t("templates.sections.generalTemplates") }}</p>
|
||||
<h3>{{ t("templates.sections.generalTemplates") }}</h3>
|
||||
</div>
|
||||
<div class="template-cards-container">
|
||||
<article
|
||||
v-for="(template, idx) in templateListQuery.data.value || []"
|
||||
:key="template.id"
|
||||
class="template-card"
|
||||
:class="getTemplateCardClass(idx)"
|
||||
@click="startTemplate(template)"
|
||||
>
|
||||
<div class="template-icon-wrapper" :class="getTemplateBgColorClass(idx)">
|
||||
<component :is="resolveTemplateIcon(template)" class="template-icon" />
|
||||
</div>
|
||||
<h4 class="template-title">{{ template.template_name }}</h4>
|
||||
<p class="template-desc">{{ getTemplateMeta(template.template_key).helper }}</p>
|
||||
<div class="template-action">
|
||||
<span>{{ getTemplateMeta(template.template_key).action }}</span>
|
||||
<ArrowRightOutlined class="template-arrow" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pickerMode === 'refined'" class="templates-view__drawer-panel">
|
||||
<div class="templates-view__drawer-panel-head">
|
||||
<p class="eyebrow">{{ t("templates.sections.refinedTemplates") }}</p>
|
||||
<h3>{{ t("templates.sections.refinedTemplates") }}</h3>
|
||||
</div>
|
||||
<div class="templates-view__kol-grid" v-if="kolCardsQuery.data.value?.length">
|
||||
<article
|
||||
v-for="card in kolCardsQuery.data.value"
|
||||
:key="card.subscription_prompt_id"
|
||||
class="templates-view__kol-card"
|
||||
@click="openRefinedTemplate(card)"
|
||||
>
|
||||
<div
|
||||
class="templates-view__kol-cover"
|
||||
:style="card.package_cover ? { backgroundImage: `url(${card.package_cover})` } : {}"
|
||||
>
|
||||
<div v-if="!card.package_cover" class="templates-view__kol-cover-fallback"></div>
|
||||
</div>
|
||||
<div class="templates-view__kol-content">
|
||||
<div class="templates-view__kol-header">
|
||||
<h4 class="templates-view__kol-title">{{ card.package_name }}</h4>
|
||||
<div class="templates-view__kol-subtitle">
|
||||
<span>{{ card.prompt_name }}</span>
|
||||
<a-tag v-if="card.platform_hint" color="blue" size="small">{{ card.platform_hint }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="templates-view__kol-footer">
|
||||
<span class="templates-view__kol-author">{{ card.kol_display_name }}</span>
|
||||
<span class="templates-view__kol-action">
|
||||
{{ t("kol.generate.title") }}
|
||||
<ArrowRightOutlined />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else :description="t('kol.marketplace.empty')" />
|
||||
</div>
|
||||
|
||||
<a-empty v-else :description="t('templates.picker.empty')" />
|
||||
</a-drawer>
|
||||
|
||||
<PublishArticleModal
|
||||
v-model:open="publishModalOpen"
|
||||
@@ -572,6 +757,11 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.templates-view__create-btn {
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.batch-generate-btn {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
@@ -639,6 +829,97 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.templates-view__drawer-intro {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.templates-view__drawer-intro p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-card {
|
||||
width: 100%;
|
||||
border: 1px solid #dbe7f5;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-card:hover,
|
||||
.templates-view__drawer-mode-card.is-active {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 12px 24px rgba(22, 119, 255, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-icon--general {
|
||||
background: linear-gradient(180deg, #fff4d6 0%, #ffe7ad 100%);
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-icon--refined {
|
||||
background: linear-gradient(180deg, #e6f4ff 0%, #cfe3ff 100%);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-copy strong {
|
||||
font-size: 16px;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-copy small {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.templates-view__drawer-panel {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.templates-view__drawer-panel-head {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.templates-view__drawer-panel-head h3 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 20px;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.templates-view__title-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -649,10 +930,11 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
flex: 1;
|
||||
flex: 1 1 220px;
|
||||
border-radius: 12px;
|
||||
padding: 20px 16px;
|
||||
cursor: pointer;
|
||||
@@ -733,6 +1015,91 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.templates-view__kol-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.templates-view__kol-card {
|
||||
background: #fcfcfc;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.templates-view__kol-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.05);
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.templates-view__kol-cover {
|
||||
height: 100px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.templates-view__kol-cover-fallback {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
|
||||
}
|
||||
|
||||
.templates-view__kol-content {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.templates-view__kol-header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.templates-view__kol-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.templates-view__kol-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.templates-view__kol-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.templates-view__kol-author {
|
||||
color: #bfbfbf;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.templates-view__kol-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.templates-view__filter-item :deep(.ant-picker) {
|
||||
width: 280px;
|
||||
@@ -743,9 +1110,14 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
.card-grey .template-action { display: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.templates-view__header,
|
||||
.templates-view__table-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.templates-view__drawer-mode-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -216,7 +216,7 @@ function refreshDashboard(): void {
|
||||
<div class="workspace-header-grid">
|
||||
<!-- Templates Section -->
|
||||
<section class="panel panel-templates">
|
||||
<h3 class="panel-title">{{ t("workspace.sections.templates") }}</h3>
|
||||
<h3 class="panel-title">{{ t("workspace.sections.generalTemplates") }}</h3>
|
||||
|
||||
<div class="template-cards-container" v-if="templateCardsQuery.data.value?.length">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user