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:
2026-04-18 13:47:32 +08:00
parent 3ef0807456
commit b2605abd6a
27 changed files with 2051 additions and 171 deletions
+76 -3
View File
@@ -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;
}
+49 -1
View File
@@ -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>
+444 -72
View File
@@ -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>
+1 -1
View File
@@ -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