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:
@@ -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("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return escapeHtml(value).replaceAll('"', """);
|
||||
}
|
||||
</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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -190,7 +190,7 @@ const router = createRouter({
|
||||
component: () => import("@/views/KolGenerateView.vue"),
|
||||
meta: {
|
||||
titleKey: "kol.generate.title",
|
||||
navKey: "/kol/marketplace",
|
||||
navKey: "/articles/templates",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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