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
@@ -8,7 +8,6 @@ import { Modal } from "ant-design-vue";
import {
buildKolPlaceholderToken,
createKolVariableDefinition,
findKolPlaceholderKeyAtPosition,
} from "@/lib/kol-placeholders";
const props = defineProps<{
@@ -29,6 +28,16 @@ const { t } = useI18n();
const aiDescription = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const highlightRef = ref<HTMLDivElement | null>(null);
let syncingScroll = false;
type PointerSelectionState = {
key: string | null;
pointerId: number;
startX: number;
startY: number;
};
let pointerSelectionState: PointerSelectionState | null = null;
const editorPlaceholder = "在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量";
const highlightTokenRE = /\{\{\s*([^{}]+?)\s*\}\}/g;
@@ -45,9 +54,20 @@ const highlightedContent = computed(() => {
for (const match of text.matchAll(highlightTokenRE)) {
const matchIndex = match.index ?? 0;
const token = match[0] ?? "";
const key = match[1] ?? "";
const keyOffset = token.indexOf(key);
const prefix = keyOffset >= 0 ? token.slice(0, keyOffset) : token;
const suffix = keyOffset >= 0 ? token.slice(keyOffset + key.length) : "";
const keyStart = matchIndex + Math.max(keyOffset, 0);
html += escapeHtml(text.slice(lastIndex, matchIndex));
html += `<span class="token-highlight">${escapeHtml(token)}</span>`;
html += [
`<span class="token-highlight">`,
escapeHtml(prefix),
`<span class="token-highlight__content" data-kol-key="${escapeAttribute(key)}" data-kol-start="${keyStart}">${escapeHtml(key)}</span>`,
escapeHtml(suffix),
`</span>`,
].join("");
lastIndex = matchIndex + token.length;
}
@@ -107,7 +127,7 @@ function insertToken(token: string) {
setTimeout(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
syncScroll();
syncScrollFromTextarea();
}, 0);
}
@@ -118,34 +138,280 @@ function handleAiGenerate() {
function handleContentInput(event: Event) {
emit("update:content", (event.target as HTMLTextAreaElement).value);
syncScroll();
syncScrollFromTextarea();
}
function handleCaretInteraction() {
if (!textareaRef.value) {
function handleHighlightPointerDown(event: PointerEvent) {
pointerSelectionState = {
key: getPlaceholderKeyFromTarget(event.target),
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
};
}
function handleHighlightPointerUp(event: PointerEvent) {
const textarea = textareaRef.value;
const highlight = highlightRef.value;
const pointerState = pointerSelectionState;
pointerSelectionState = null;
if (!textarea || !highlight || !pointerState || pointerState.pointerId !== event.pointerId) {
return;
}
requestAnimationFrame(() => {
const textarea = textareaRef.value;
if (!textarea) {
return;
if (hasOverlaySelection(highlight)) {
return;
}
const movedDistance =
Math.abs(event.clientX - pointerState.startX) + Math.abs(event.clientY - pointerState.startY);
if (movedDistance > 6) {
return;
}
event.preventDefault();
const cursorPos = getCaretIndexFromPoint(highlight, event);
const scrollTop = textarea.scrollTop;
const scrollLeft = textarea.scrollLeft;
textarea.setSelectionRange(cursorPos, cursorPos);
focusTextareaWithoutScroll(textarea);
restoreTextareaScroll(textarea, scrollTop, scrollLeft);
if (pointerState.key) {
emit("focus-variable", pointerState.key);
}
}
function handleHighlightPointerCancel() {
pointerSelectionState = null;
}
function getPlaceholderKeyFromTarget(target: EventTarget | null): string | null {
const targetElement =
target instanceof HTMLElement
? target
: target instanceof Text
? target.parentElement
: null;
return targetElement?.closest<HTMLElement>("[data-kol-key]")?.dataset.kolKey?.trim() || null;
}
function hasOverlaySelection(container: HTMLElement): boolean {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return !!anchorNode && !!focusNode && container.contains(anchorNode) && container.contains(focusNode);
}
function getCaretIndexFromPoint(container: HTMLElement, event: PointerEvent): number {
const browserIndex = getBrowserCaretIndex(container, event);
if (browserIndex !== null) {
return clampOffset(browserIndex, props.content.length);
}
const approximateIndex = getApproximateCaretIndex(container, event.clientX, event.clientY);
return clampOffset(approximateIndex, props.content.length);
}
function getBrowserCaretIndex(
container: HTMLElement,
event: PointerEvent,
): number | null {
if ("caretPositionFromPoint" in document) {
const position = document.caretPositionFromPoint(event.clientX, event.clientY);
const index = resolveCaretIndex(container, position?.offsetNode ?? null, position?.offset);
if (index !== null) {
return index;
}
}
if ("caretRangeFromPoint" in document) {
const range = document.caretRangeFromPoint(event.clientX, event.clientY);
const index = resolveCaretIndex(container, range?.startContainer ?? null, range?.startOffset);
if (index !== null) {
return index;
}
}
return null;
}
function resolveCaretIndex(
container: HTMLElement,
node: Node | null,
offset?: number,
): number | null {
if (!node || (node !== container && !container.contains(node))) {
return null;
}
const range = document.createRange();
range.setStart(container, 0);
range.setEnd(node, offset ?? 0);
return range.toString().length;
}
function getApproximateCaretIndex(
container: HTMLElement,
clientX: number,
clientY: number,
): number {
const textNodes = getTextNodes(container);
if (textNodes.length === 0) {
return 0;
}
const range = document.createRange();
let indexOffset = 0;
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
let sawLineMatch = false;
for (const textNode of textNodes) {
const textLength = textNode.textContent?.length ?? 0;
for (let index = 0; index < textLength; index += 1) {
range.setStart(textNode, index);
range.setEnd(textNode, index + 1);
const rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) {
continue;
}
const isSameLine = clientY >= rect.top && clientY <= rect.bottom;
const midpoint = rect.left + rect.width / 2;
if (isSameLine) {
sawLineMatch = true;
if (clientX <= midpoint) {
return indexOffset + index;
}
bestIndex = indexOffset + index + 1;
continue;
}
const deltaY =
clientY < rect.top
? rect.top - clientY
: clientY > rect.bottom
? clientY - rect.bottom
: 0;
const deltaX =
clientX < rect.left
? rect.left - clientX
: clientX > rect.right
? clientX - rect.right
: 0;
const distance = deltaY * deltaY + deltaX * deltaX;
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = clientX <= midpoint ? indexOffset + index : indexOffset + index + 1;
}
}
const key = findKolPlaceholderKeyAtPosition(props.content, textarea.selectionStart ?? -1);
if (key) {
emit("focus-variable", key);
indexOffset += textLength;
}
if (sawLineMatch) {
return bestIndex;
}
return bestIndex;
}
function getTextNodes(container: HTMLElement): Text[] {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!(node instanceof Text)) {
return NodeFilter.FILTER_REJECT;
}
return node.textContent?.length ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const nodes: Text[] = [];
while (walker.nextNode()) {
const currentNode = walker.currentNode;
if (currentNode instanceof Text) {
nodes.push(currentNode);
}
}
return nodes;
}
function clampOffset(offset: number, textLength: number): number {
if (!Number.isFinite(offset)) {
return 0;
}
return Math.min(Math.max(offset, 0), textLength);
}
function focusTextareaWithoutScroll(textarea: HTMLTextAreaElement) {
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
}
function restoreTextareaScroll(
textarea: HTMLTextAreaElement,
scrollTop: number,
scrollLeft: number,
) {
textarea.scrollTop = scrollTop;
textarea.scrollLeft = scrollLeft;
syncScrollFromTextarea();
requestAnimationFrame(() => {
textarea.scrollTop = scrollTop;
textarea.scrollLeft = scrollLeft;
syncScrollFromTextarea();
});
}
function syncScroll() {
function syncScrollFromTextarea() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
if (syncingScroll) {
return;
}
syncingScroll = true;
highlightRef.value.scrollTop = textareaRef.value.scrollTop;
highlightRef.value.scrollLeft = textareaRef.value.scrollLeft;
requestAnimationFrame(() => {
syncingScroll = false;
});
}
function syncScrollFromHighlight() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
if (syncingScroll) {
return;
}
syncingScroll = true;
textareaRef.value.scrollTop = highlightRef.value.scrollTop;
textareaRef.value.scrollLeft = highlightRef.value.scrollLeft;
requestAnimationFrame(() => {
syncingScroll = false;
});
}
function escapeHtml(value: string): string {
@@ -154,6 +420,10 @@ function escapeHtml(value: string): string {
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}
</script>
<template>
@@ -200,6 +470,10 @@ function escapeHtml(value: string): string {
class="prompt-highlight"
aria-hidden="true"
v-html="highlightedContent"
@pointerdown="handleHighlightPointerDown"
@pointerup="handleHighlightPointerUp"
@pointercancel="handleHighlightPointerCancel"
@scroll="syncScrollFromHighlight"
></div>
<textarea
ref="textareaRef"
@@ -207,9 +481,7 @@ function escapeHtml(value: string): string {
:value="content"
:placeholder="editorPlaceholder"
@input="handleContentInput"
@mouseup="handleCaretInteraction"
@keyup="handleCaretInteraction"
@scroll="syncScroll"
@scroll="syncScrollFromTextarea"
></textarea>
</div>
</div>
@@ -250,16 +522,21 @@ function escapeHtml(value: string): string {
.prompt-highlight {
position: absolute;
inset: 0;
z-index: 2;
overflow: auto;
padding: 24px;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: normal;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
letter-spacing: normal;
color: #262626;
pointer-events: none;
user-select: none;
cursor: text;
user-select: text;
box-sizing: border-box;
}
.prompt-textarea {
@@ -273,9 +550,14 @@ function escapeHtml(value: string): string {
font-family: inherit;
font-size: 16px;
line-height: 1.6;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: normal;
letter-spacing: normal;
outline: none;
color: transparent;
caret-color: #262626;
box-sizing: border-box;
}
.prompt-textarea::placeholder {
@@ -284,7 +566,10 @@ function escapeHtml(value: string): string {
.prompt-highlight :deep(.token-highlight) {
color: #eb2f96;
font-weight: 600;
}
.prompt-highlight :deep(.token-highlight__content) {
cursor: text;
}
.prompt-highlight :deep(.editor-placeholder) {
@@ -10,9 +10,10 @@ import KolVariableConfig from "./KolVariableConfig.vue";
import KolPromptEditArea from "./KolPromptEditArea.vue";
import KolDynamicForm from "./KolDynamicForm.vue";
import { kolManageApi } from "@/lib/api";
import type { KolVariableDefinition } from "@geo/shared-types";
import type { KolCardConfig, KolVariableDefinition } from "@geo/shared-types";
import { formatError } from "@/lib/errors";
import { buildKolPlatformOptions } from "@/lib/kol-platform-options";
import { mergeKolCardConfig, parseKolCardConfig } from "@/lib/kol-card-config";
import { syncKolVariablesWithContent } from "@/lib/kol-placeholders";
const props = defineProps<{
@@ -31,10 +32,15 @@ const variables = ref<KolVariableDefinition[]>([]);
const previewOpen = ref(false);
const previewValues = ref<Record<string, any>>({});
const aiTaskId = ref<string | null>(null);
const variableConfigRef = ref<{ focusVariable: (key: string) => void } | null>(null);
const cardConfig = ref<KolCardConfig>({});
const variableConfigRef = ref<{
focusVariable: (key: string, options?: { focusInput?: boolean }) => void;
} | null>(null);
const metadataForm = reactive({
name: "",
platform_hint: "通用",
allow_web_search: false,
allow_user_knowledge: true,
});
const { data: promptDetail } = useQuery({
@@ -55,10 +61,16 @@ watch(
() => promptDetail.value,
(detail) => {
if (detail) {
const nextCardConfig = detail.latest_card_config_json ?? {};
const parsedCardConfig = parseKolCardConfig(nextCardConfig);
metadataForm.name = detail.name;
metadataForm.platform_hint = detail.platform_hint || "通用";
metadataForm.allow_web_search = parsedCardConfig.allow_web_search;
metadataForm.allow_user_knowledge = parsedCardConfig.allow_user_knowledge;
content.value = detail.latest_prompt_content ?? "";
variables.value = detail.latest_schema_json?.variables ?? [];
cardConfig.value = { ...nextCardConfig };
}
},
{ immediate: true }
@@ -148,7 +160,10 @@ function buildPromptPayload(nextVariables: KolVariableDefinition[] = variables.v
sort_order: promptDetail.value?.sort_order,
content: content.value,
schema: { variables: nextVariables },
card_config: {},
card_config: mergeKolCardConfig(cardConfig.value, {
allow_web_search: metadataForm.allow_web_search,
allow_user_knowledge: metadataForm.allow_user_knowledge,
}),
};
}
@@ -214,7 +229,7 @@ function handlePreview() {
async function handleFocusVariable(key: string) {
syncVariablesFromContent();
await nextTick();
variableConfigRef.value?.focusVariable(key);
variableConfigRef.value?.focusVariable(key, { focusInput: false });
}
function getSelectPopupContainer(triggerNode: HTMLElement) {
@@ -288,6 +303,20 @@ function statusLabel(status?: string): string {
:get-popup-container="getSelectPopupContainer"
/>
</a-form-item>
<a-form-item label="生成文章时允许联网搜索">
<div class="editor-switch-field">
<a-switch v-model:checked="metadataForm.allow_web_search" />
<span class="editor-switch-hint">默认关闭关闭后用户在生成页无法启用联网搜索</span>
</div>
</a-form-item>
<a-form-item label="生成文章时允许用户使用自己的知识库">
<div class="editor-switch-field">
<a-switch v-model:checked="metadataForm.allow_user_knowledge" />
<span class="editor-switch-hint">默认开启关闭后用户在生成页无法选择自己的知识库</span>
</div>
</a-form-item>
</a-form>
</div>
@@ -432,6 +461,19 @@ function statusLabel(status?: string): string {
background: #f0f0f0;
}
.editor-switch-field {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.editor-switch-hint {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 1200px) {
.editor-meta-form {
grid-template-columns: 1fr 1fr;
@@ -27,6 +27,10 @@ const componentTypes: Array<{ type: KolVariableType }> = [
{ type: "checkbox" },
];
type FocusVariableOptions = {
focusInput?: boolean;
};
function updateVariable(index: number, updates: Partial<KolVariableDefinition>) {
const newVariables = [...props.variables];
newVariables[index] = { ...newVariables[index], ...updates };
@@ -66,7 +70,7 @@ function setCardRef(key: string, el: Element | ComponentPublicInstance | null) {
cardRefs.delete(key);
}
async function focusVariable(key: string) {
async function focusVariable(key: string, options: FocusVariableOptions = {}) {
activeVariableKey.value = key;
if (activeHighlightTimer !== null) {
@@ -93,6 +97,10 @@ async function focusVariable(key: string) {
behavior: "smooth",
});
if (options.focusInput === false) {
return;
}
requestAnimationFrame(() => {
const input = card.querySelector("input");
if (input instanceof HTMLInputElement) {
+16 -7
View File
@@ -79,8 +79,8 @@ const enUS = {
contentManagement: "Content Management",
knowledge: "Knowledge Base",
images: "Image Management",
kolMarket: "KOL Marketplace",
kolMarketplace: "Template Marketplace",
kolMarket: "Refined Templates",
kolMarketplace: "Refined Templates",
kolWorkspace: "KOL Workspace",
kolManage: "Prompt Management",
kolDashboard: "Dashboard",
@@ -115,7 +115,7 @@ const enUS = {
},
templates: {
title: "Template Creation",
description: "Choose a suitable template or use the batch feature to quickly generate GEO content.",
description: "Manage both general-purpose and refined templates, with all related generation records gathered here.",
},
wizard: {
title: "Template Wizard",
@@ -160,7 +160,7 @@ const enUS = {
},
kol: {
marketplace: {
title: "KOL Template Marketplace",
title: "Refined Templates",
filter: {
industry: "Industry",
keyword: "Keyword",
@@ -179,7 +179,7 @@ const enUS = {
notSubscribed: "Not subscribed",
},
generate: {
title: "Generate Article",
title: "Refined Template Generation",
submit: "Generate",
fillVariables: "Please fill in the following variables and click generate",
},
@@ -242,7 +242,7 @@ const enUS = {
platforms: "Platforms",
},
sections: {
templates: "Article Templates",
generalTemplates: "General-Purpose Templates",
recent: "Recent Generations",
overview: "Overview",
quota: "Plan & Quota",
@@ -441,7 +441,7 @@ const enUS = {
instant_task: "Instant task",
schedule_task: "Scheduled task",
free_create: "Free create",
kol: "KOL generation",
kol: "Refined template generation",
},
},
freeCreate: {
@@ -469,6 +469,11 @@ const enUS = {
},
templates: {
eyebrow: "Article Creation",
sections: {
generalTemplates: "General-Purpose Templates",
refinedTemplates: "Refined Templates",
records: "Template Creation Records",
},
actions: {
batchGenerate: "Batch generate",
chooseTemplate: "Choose template",
@@ -484,6 +489,7 @@ const enUS = {
keywordPlaceholder: "Search by article title",
},
list: {
eyebrow: "Template Creation Records",
count: "{count} articles",
empty: "No articles have been created yet.",
preview: "Preview",
@@ -497,6 +503,9 @@ const enUS = {
},
picker: {
title: "Choose the template you want to use",
empty: "Choose either general-purpose templates or refined template generation first",
generalDescription: "Choose a general-purpose template to start creating",
refinedDescription: "Choose a refined template and start refined generation directly",
viewExample: "View example",
},
wizard: {
+16 -7
View File
@@ -79,8 +79,8 @@ const zhCN = {
contentManagement: "内容管理",
knowledge: "知识库",
images: "图片管理",
kolMarket: "KOL 市场",
kolMarketplace: "模版市场",
kolMarket: "精调模版",
kolMarketplace: "精调模版",
kolWorkspace: "KOL 工作台",
kolManage: "提示词管理",
kolDashboard: "数据看板",
@@ -115,7 +115,7 @@ const zhCN = {
},
templates: {
title: "模版创作",
description: "选择合适的模版或使用批量功能,快速生成GEO内容。",
description: "统一查看普通通用模版与精调模版,相关生成记录都会在这里汇总展示。",
},
wizard: {
title: "模版向导",
@@ -160,7 +160,7 @@ const zhCN = {
},
kol: {
marketplace: {
title: "KOL 模版市场",
title: "精调模版",
filter: {
industry: "行业",
keyword: "关键字",
@@ -179,7 +179,7 @@ const zhCN = {
notSubscribed: "未订阅",
},
generate: {
title: "生成文章",
title: "精调模版生成",
submit: "生成",
fillVariables: "填写以下变量后点击生成",
},
@@ -242,7 +242,7 @@ const zhCN = {
platforms: "媒体平台数",
},
sections: {
templates: "文章模版类型",
generalTemplates: "普通通用模版",
recent: "最近生成",
overview: "数据总览",
quota: "套餐与额度",
@@ -441,7 +441,7 @@ const zhCN = {
instant_task: "即时任务",
schedule_task: "定时任务",
free_create: "自由创作",
kol: "KOL 生成",
kol: "精调模版生成",
},
},
freeCreate: {
@@ -469,6 +469,11 @@ const zhCN = {
},
templates: {
eyebrow: "Article Creation",
sections: {
generalTemplates: "普通通用模版",
refinedTemplates: "精调模版",
records: "模版创作记录",
},
actions: {
batchGenerate: "批量生成文章",
chooseTemplate: "选择模版创作",
@@ -484,6 +489,7 @@ const zhCN = {
keywordPlaceholder: "请输入标题内容搜索",
},
list: {
eyebrow: "模版创作记录",
count: "共 {count} 篇文章",
empty: "还未创建文章,暂无相关数据~",
preview: "预览正文",
@@ -497,6 +503,9 @@ const zhCN = {
},
picker: {
title: "选择你需要的模版创作",
empty: "请选择普通通用模版或精调模版生成",
generalDescription: "选择普通通用模版进入创作",
refinedDescription: "选择精调模版后直接发起精调模版生成",
viewExample: "查看示例",
},
wizard: {
+38
View File
@@ -0,0 +1,38 @@
import type { KolCardConfig } from "@geo/shared-types";
const DEFAULT_ALLOW_WEB_SEARCH = false;
const DEFAULT_ALLOW_USER_KNOWLEDGE = true;
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function booleanFromUnknown(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function parseKolCardConfig(value: unknown): Required<Pick<KolCardConfig, "allow_web_search" | "allow_user_knowledge">> {
const record = asRecord(value);
return {
allow_web_search: booleanFromUnknown(record.allow_web_search) ?? DEFAULT_ALLOW_WEB_SEARCH,
allow_user_knowledge:
booleanFromUnknown(record.allow_user_knowledge) ?? DEFAULT_ALLOW_USER_KNOWLEDGE,
};
}
export function mergeKolCardConfig(base: unknown, overrides: KolCardConfig): KolCardConfig {
const record = asRecord(base);
const parsed = parseKolCardConfig(record);
return {
...record,
...overrides,
allow_web_search:
booleanFromUnknown(overrides.allow_web_search) ?? parsed.allow_web_search,
allow_user_knowledge:
booleanFromUnknown(overrides.allow_user_knowledge) ?? parsed.allow_user_knowledge,
};
}
+1 -1
View File
@@ -190,7 +190,7 @@ const router = createRouter({
component: () => import("@/views/KolGenerateView.vue"),
meta: {
titleKey: "kol.generate.title",
navKey: "/kol/marketplace",
navKey: "/articles/templates",
},
},
],
+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