feat(imitation): add article imitation create flow
Introduce 仿写创作: new list and create views, backend imitation service and prompt templates, worker task routing for imitation jobs, and one-click rewrite triggers from question citation sources. Surface source article URL/title on article list/detail, and restrict failed articles to delete-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CopyOutlined } from "@ant-design/icons-vue";
|
import { CopyOutlined, LinkOutlined } from "@ant-design/icons-vue";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import type { TableColumnsType } from "ant-design-vue";
|
import type { TableColumnsType } from "ant-design-vue";
|
||||||
@@ -122,6 +122,8 @@ const generateMeta = computed(() =>
|
|||||||
getGenerateStatusMeta(streamStatus.value || detail.value?.generate_status),
|
getGenerateStatusMeta(streamStatus.value || detail.value?.generate_status),
|
||||||
);
|
);
|
||||||
const publishMeta = computed(() => getPublishStatusMeta(detail.value?.publish_status));
|
const publishMeta = computed(() => getPublishStatusMeta(detail.value?.publish_status));
|
||||||
|
const sourceArticleTitle = computed(() => detail.value?.source_title?.trim() || "");
|
||||||
|
const sourceArticleURL = computed(() => detail.value?.source_url?.trim() || "");
|
||||||
|
|
||||||
function handleClose(): void {
|
function handleClose(): void {
|
||||||
activeTab.value = "content";
|
activeTab.value = "content";
|
||||||
@@ -284,6 +286,27 @@ async function copyPublishLink(record: PublishRecord): Promise<void> {
|
|||||||
<a-descriptions-item :label="t('article.meta.wordCount')">
|
<a-descriptions-item :label="t('article.meta.wordCount')">
|
||||||
{{ detail.word_count || "--" }}
|
{{ detail.word_count || "--" }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item
|
||||||
|
v-if="sourceArticleURL"
|
||||||
|
:label="t('article.meta.sourceArticle')"
|
||||||
|
:span="2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="article-drawer__source-link"
|
||||||
|
:href="sourceArticleURL"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="sourceArticleURL"
|
||||||
|
>
|
||||||
|
<LinkOutlined />
|
||||||
|
<span class="article-drawer__source-copy">
|
||||||
|
<span v-if="sourceArticleTitle" class="article-drawer__source-title">
|
||||||
|
{{ sourceArticleTitle }}
|
||||||
|
</span>
|
||||||
|
<span class="article-drawer__source-url">{{ sourceArticleURL }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -423,6 +446,37 @@ async function copyPublishLink(record: PublishRecord): Promise<void> {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-drawer__source-link {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-drawer__source-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-drawer__source-title,
|
||||||
|
.article-drawer__source-url {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-drawer__source-title {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-drawer__source-url {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
.article-drawer__alert {
|
.article-drawer__alert {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
CopyOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
@@ -24,7 +25,7 @@ const sourceLabel = computed(() =>
|
|||||||
const createdAtLabel = computed(() => formatDateTime(props.createdAt));
|
const createdAtLabel = computed(() => formatDateTime(props.createdAt));
|
||||||
|
|
||||||
const sourceVariant = computed<
|
const sourceVariant = computed<
|
||||||
"template" | "custom" | "instant" | "schedule" | "free-create" | "kol" | "default"
|
"template" | "custom" | "instant" | "schedule" | "free-create" | "imitation" | "kol" | "default"
|
||||||
>(() => {
|
>(() => {
|
||||||
if (props.sourceType === "template") {
|
if (props.sourceType === "template") {
|
||||||
return "template";
|
return "template";
|
||||||
@@ -32,6 +33,9 @@ const sourceVariant = computed<
|
|||||||
if (props.sourceType === "free_create") {
|
if (props.sourceType === "free_create") {
|
||||||
return "free-create";
|
return "free-create";
|
||||||
}
|
}
|
||||||
|
if (props.sourceType === "imitation") {
|
||||||
|
return "imitation";
|
||||||
|
}
|
||||||
if (props.sourceType === "kol") {
|
if (props.sourceType === "kol") {
|
||||||
return "kol";
|
return "kol";
|
||||||
}
|
}
|
||||||
@@ -59,6 +63,8 @@ const badgeIcon = computed<Component>(() => {
|
|||||||
return ThunderboltOutlined;
|
return ThunderboltOutlined;
|
||||||
case "free-create":
|
case "free-create":
|
||||||
return EditOutlined;
|
return EditOutlined;
|
||||||
|
case "imitation":
|
||||||
|
return CopyOutlined;
|
||||||
case "kol":
|
case "kol":
|
||||||
return TeamOutlined;
|
return TeamOutlined;
|
||||||
default:
|
default:
|
||||||
@@ -133,6 +139,12 @@ const badgeIcon = computed<Component>(() => {
|
|||||||
color: #18794e;
|
color: #18794e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-source-meta__badge--imitation {
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #e6f0ff 100%);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.article-source-meta__badge--kol {
|
.article-source-meta__badge--kol {
|
||||||
border: 1px solid #d6e4ff;
|
border: 1px solid #d6e4ff;
|
||||||
background: linear-gradient(180deg, #f5f8ff 0%, #eaf1ff 100%);
|
background: linear-gradient(180deg, #f5f8ff 0%, #eaf1ff 100%);
|
||||||
|
|||||||
@@ -508,13 +508,6 @@ function normalizePlatformUid(value?: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<a-alert
|
|
||||||
type="info"
|
|
||||||
show-icon
|
|
||||||
message="发布调度规则"
|
|
||||||
description="客户端在线时会立即开始消费;客户端离线时也允许提交,任务会先写入 SaaS 队列,客户端恢复在线后继续执行。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="publish-modal__section">
|
<section class="publish-modal__section">
|
||||||
<div class="publish-modal__section-header">
|
<div class="publish-modal__section-header">
|
||||||
<h3><span class="required-star">*</span> 目标账号</h3>
|
<h3><span class="required-star">*</span> 目标账号</h3>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const enUS = {
|
|||||||
templates: "Templates",
|
templates: "Templates",
|
||||||
custom: "Custom Generation",
|
custom: "Custom Generation",
|
||||||
freeCreate: "Free Create",
|
freeCreate: "Free Create",
|
||||||
|
imitation: "Imitation Create",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
brandManagement: "Brand Management",
|
brandManagement: "Brand Management",
|
||||||
brands: "Brand Library",
|
brands: "Brand Library",
|
||||||
@@ -150,6 +151,14 @@ const enUS = {
|
|||||||
title: "Free Create",
|
title: "Free Create",
|
||||||
description: "Create and edit articles freely, no templates or AI generation required.",
|
description: "Create and edit articles freely, no templates or AI generation required.",
|
||||||
},
|
},
|
||||||
|
imitation: {
|
||||||
|
title: "Imitation Create",
|
||||||
|
description: "Manage rewritten articles generated from citation sources or external URLs.",
|
||||||
|
},
|
||||||
|
imitationCreate: {
|
||||||
|
title: "Imitation Create",
|
||||||
|
description: "Extract an article from a URL, then generate a new article in one step.",
|
||||||
|
},
|
||||||
media: {
|
media: {
|
||||||
title: "Media Management",
|
title: "Media Management",
|
||||||
description: "Review desktop-managed media bindings, authorization state, and sync results in one place.",
|
description: "Review desktop-managed media bindings, authorization state, and sync results in one place.",
|
||||||
@@ -385,6 +394,7 @@ const enUS = {
|
|||||||
citationAnalysisTitle: "Citation Analysis",
|
citationAnalysisTitle: "Citation Analysis",
|
||||||
contentCitations: "Content Citations",
|
contentCitations: "Content Citations",
|
||||||
contentCitationsTitle: "Content Citations",
|
contentCitationsTitle: "Content Citations",
|
||||||
|
imitationAction: "Imitation create",
|
||||||
taskDebugTitle: "Async Task Debug",
|
taskDebugTitle: "Async Task Debug",
|
||||||
taskDebugHint: "For troubleshooting the callback -> queue -> worker flow only. The four main content sections above stay unchanged.",
|
taskDebugHint: "For troubleshooting the callback -> queue -> worker flow only. The four main content sections above stay unchanged.",
|
||||||
contentCitationBadge: "Content Citation",
|
contentCitationBadge: "Content Citation",
|
||||||
@@ -551,9 +561,76 @@ const enUS = {
|
|||||||
instant_task: "Instant task",
|
instant_task: "Instant task",
|
||||||
schedule_task: "Scheduled task",
|
schedule_task: "Scheduled task",
|
||||||
free_create: "Free create",
|
free_create: "Free create",
|
||||||
|
imitation: "Imitation create",
|
||||||
kol: "Refined template generation",
|
kol: "Refined template generation",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
imitation: {
|
||||||
|
actions: {
|
||||||
|
create: "New Rewrite",
|
||||||
|
submit: "Submit and generate",
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
publishStatus: "Publish status",
|
||||||
|
generateStatus: "Generation status",
|
||||||
|
keyword: "Search",
|
||||||
|
createdTime: "Created time",
|
||||||
|
createdTimeStart: "Start time",
|
||||||
|
createdTimeEnd: "End time",
|
||||||
|
keywordPlaceholder: "Search by article title",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
eyebrow: "Imitation Create",
|
||||||
|
count: "{count} articles",
|
||||||
|
sourceArticle: "Source",
|
||||||
|
empty: "No rewritten articles yet. Start from a citation source or create one manually.",
|
||||||
|
deleteConfirm: "Delete this rewritten article?",
|
||||||
|
deleteSuccess: "Article deleted.",
|
||||||
|
deleteError: "Failed to delete article.",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: "Create rewritten article",
|
||||||
|
subtitle: "The backend fetches the source article first, then generates a new original article from your settings.",
|
||||||
|
sourceSection: "Source article",
|
||||||
|
settingsSection: "Rewrite settings",
|
||||||
|
sourceURL: "Article URL",
|
||||||
|
sourceTitle: "Source title",
|
||||||
|
language: "Article language",
|
||||||
|
industry: "Industry / scenario",
|
||||||
|
brandName: "Brand / subject",
|
||||||
|
region: "Region",
|
||||||
|
targetAudience: "Target audience",
|
||||||
|
contentGoal: "Content goal",
|
||||||
|
tone: "Tone",
|
||||||
|
lengthGoal: "Length",
|
||||||
|
keywords: "Keywords",
|
||||||
|
preservePoints: "Keep / emphasize",
|
||||||
|
avoidPoints: "Avoid",
|
||||||
|
extraRequirements: "Extra requirements",
|
||||||
|
enableWebSearch: "Allow web search supplement",
|
||||||
|
webSearchHint: "When enabled, generation can reference live web information for newer facts or industry context.",
|
||||||
|
knowledgeBase: "Reference knowledge base",
|
||||||
|
knowledgeBasePlaceholder: "Optional. Add facts, brand material, and industry background.",
|
||||||
|
knowledgeBaseHint: "Selected groups are retrieved in the backend and passed to generation as reference context.",
|
||||||
|
sourceURLPlaceholder: "https://example.com/article",
|
||||||
|
sourceTitlePlaceholder: "Optional. Citation titles are prefilled automatically.",
|
||||||
|
industryPlaceholder: "For example: home improvement, education, SaaS, healthcare",
|
||||||
|
brandNamePlaceholder: "Optional. Helps the article fit a specific brand or subject.",
|
||||||
|
regionPlaceholder: "For example: Shanghai, East China, nationwide, North America",
|
||||||
|
targetAudiencePlaceholder: "For example: first-time buyers, channel partners, procurement leads",
|
||||||
|
contentGoalPlaceholder: "For example: education, conversion, brand trust, selection advice",
|
||||||
|
tonePlaceholder: "For example: professional, conversational, comparative, news analysis",
|
||||||
|
lengthGoalPlaceholder: "For example: around 1200 words, short post, long-form guide",
|
||||||
|
keywordsPlaceholder: "Type keywords and press Enter",
|
||||||
|
preservePointsPlaceholder: "List facts, data, arguments, or examples that must be kept.",
|
||||||
|
avoidPointsPlaceholder: "List claims, competitors, compliance risks, or wording to avoid.",
|
||||||
|
extraRequirementsPlaceholder: "Add channel, structure, title style, or industry-specific instructions.",
|
||||||
|
queued: "Rewrite task submitted and queued.",
|
||||||
|
sourceURLRequired: "Enter the source article URL first.",
|
||||||
|
submitError: "Failed to submit rewrite task.",
|
||||||
|
backToList: "Back to rewrites",
|
||||||
|
},
|
||||||
|
},
|
||||||
freeCreate: {
|
freeCreate: {
|
||||||
actions: {
|
actions: {
|
||||||
create: "New Article",
|
create: "New Article",
|
||||||
@@ -856,6 +933,7 @@ const enUS = {
|
|||||||
templateType: "Template",
|
templateType: "Template",
|
||||||
currentVersion: "Current version",
|
currentVersion: "Current version",
|
||||||
source: "Source label",
|
source: "Source label",
|
||||||
|
sourceArticle: "Source article",
|
||||||
wordCount: "Word count",
|
wordCount: "Word count",
|
||||||
},
|
},
|
||||||
versionUntitled: "Untitled version",
|
versionUntitled: "Untitled version",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const zhCN = {
|
|||||||
templates: "模版创作",
|
templates: "模版创作",
|
||||||
custom: "自定义生成",
|
custom: "自定义生成",
|
||||||
freeCreate: "自由创作",
|
freeCreate: "自由创作",
|
||||||
|
imitation: "仿写创作",
|
||||||
media: "媒体管理",
|
media: "媒体管理",
|
||||||
brandManagement: "品牌管理",
|
brandManagement: "品牌管理",
|
||||||
brands: "品牌词库",
|
brands: "品牌词库",
|
||||||
@@ -150,6 +151,14 @@ const zhCN = {
|
|||||||
title: "自由创作",
|
title: "自由创作",
|
||||||
description: "自由创建并编辑文章,无需模版或AI生成。",
|
description: "自由创建并编辑文章,无需模版或AI生成。",
|
||||||
},
|
},
|
||||||
|
imitation: {
|
||||||
|
title: "仿写创作",
|
||||||
|
description: "管理基于引用来源或外部文章发起的仿写生成记录。",
|
||||||
|
},
|
||||||
|
imitationCreate: {
|
||||||
|
title: "仿写创作",
|
||||||
|
description: "从文章链接提取内容后,一步生成适配当前场景的新文章。",
|
||||||
|
},
|
||||||
media: {
|
media: {
|
||||||
title: "媒体管理",
|
title: "媒体管理",
|
||||||
description: "统一查看桌面端媒体账号绑定、授权状态与同步结果。",
|
description: "统一查看桌面端媒体账号绑定、授权状态与同步结果。",
|
||||||
@@ -385,6 +394,7 @@ const zhCN = {
|
|||||||
citationAnalysisTitle: "引用分析",
|
citationAnalysisTitle: "引用分析",
|
||||||
contentCitations: "Content Citations",
|
contentCitations: "Content Citations",
|
||||||
contentCitationsTitle: "内容引用",
|
contentCitationsTitle: "内容引用",
|
||||||
|
imitationAction: "仿写创作",
|
||||||
taskDebugTitle: "异步任务调试",
|
taskDebugTitle: "异步任务调试",
|
||||||
taskDebugHint: "仅用于排查 callback -> queue -> worker 的异步流转,不影响上方四块主内容。",
|
taskDebugHint: "仅用于排查 callback -> queue -> worker 的异步流转,不影响上方四块主内容。",
|
||||||
contentCitationBadge: "内容引用",
|
contentCitationBadge: "内容引用",
|
||||||
@@ -551,9 +561,76 @@ const zhCN = {
|
|||||||
instant_task: "即时任务",
|
instant_task: "即时任务",
|
||||||
schedule_task: "定时任务",
|
schedule_task: "定时任务",
|
||||||
free_create: "自由创作",
|
free_create: "自由创作",
|
||||||
|
imitation: "仿写创作",
|
||||||
kol: "精调模版生成",
|
kol: "精调模版生成",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
imitation: {
|
||||||
|
actions: {
|
||||||
|
create: "新建仿写",
|
||||||
|
submit: "提交并生成",
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
publishStatus: "发布状态",
|
||||||
|
generateStatus: "生成状态",
|
||||||
|
keyword: "搜索文章",
|
||||||
|
createdTime: "创建时间",
|
||||||
|
createdTimeStart: "开始时间",
|
||||||
|
createdTimeEnd: "结束时间",
|
||||||
|
keywordPlaceholder: "请输入标题内容搜索",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
eyebrow: "仿写创作",
|
||||||
|
count: "共 {count} 篇文章",
|
||||||
|
sourceArticle: "原文",
|
||||||
|
empty: "还未创建仿写文章,可从问题详情的引用来源一键带入,也可以手动新建。",
|
||||||
|
deleteConfirm: "确认删除这篇仿写文章吗?",
|
||||||
|
deleteSuccess: "文章已删除",
|
||||||
|
deleteError: "删除文章失败",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: "创建仿写文章",
|
||||||
|
subtitle: "后台会先抓取源文章内容,再按下方设定生成一篇新的原创表达文章。",
|
||||||
|
sourceSection: "来源文章",
|
||||||
|
settingsSection: "仿写设置",
|
||||||
|
sourceURL: "文章 URL",
|
||||||
|
sourceTitle: "来源标题",
|
||||||
|
language: "文章语言",
|
||||||
|
industry: "行业/场景",
|
||||||
|
brandName: "品牌/主体",
|
||||||
|
region: "地域",
|
||||||
|
targetAudience: "目标读者",
|
||||||
|
contentGoal: "内容目标",
|
||||||
|
tone: "语气风格",
|
||||||
|
lengthGoal: "篇幅要求",
|
||||||
|
keywords: "关键词",
|
||||||
|
preservePoints: "必须保留/强调",
|
||||||
|
avoidPoints: "需要规避",
|
||||||
|
extraRequirements: "其他要求",
|
||||||
|
enableWebSearch: "允许联网补充",
|
||||||
|
webSearchHint: "开启后,生成时会让大模型参考实时网页信息,适合需要最新事实或行业动态的仿写。",
|
||||||
|
knowledgeBase: "引用知识库",
|
||||||
|
knowledgeBasePlaceholder: "可选,用于补充文章事实、品牌资料和行业背景",
|
||||||
|
knowledgeBaseHint: "选择后,后台会检索相关知识片段并作为生成参考,未选择则只基于来源文章和当前设置生成。",
|
||||||
|
sourceURLPlaceholder: "https://example.com/article",
|
||||||
|
sourceTitlePlaceholder: "可选,引用来源标题会自动带入",
|
||||||
|
industryPlaceholder: "例如:家居建材、教育培训、SaaS、医疗健康",
|
||||||
|
brandNamePlaceholder: "可选,填写后文章会自然贴合该品牌或主体",
|
||||||
|
regionPlaceholder: "例如:上海、华东、全国、北美市场",
|
||||||
|
targetAudiencePlaceholder: "例如:首次购买用户、渠道代理、企业采购负责人",
|
||||||
|
contentGoalPlaceholder: "例如:科普说明、转化种草、品牌信任、选型建议",
|
||||||
|
tonePlaceholder: "例如:专业可信、轻松口语、评测对比、新闻解读",
|
||||||
|
lengthGoalPlaceholder: "例如:1200字左右、短文、深度长文",
|
||||||
|
keywordsPlaceholder: "输入关键词后回车,可多选",
|
||||||
|
preservePointsPlaceholder: "列出必须保留的事实、数据、观点或案例",
|
||||||
|
avoidPointsPlaceholder: "列出不能出现的说法、竞品、合规禁区或表达方式",
|
||||||
|
extraRequirementsPlaceholder: "补充行业、渠道、结构、标题风格等自由要求",
|
||||||
|
queued: "仿写任务已提交,正在排队生成",
|
||||||
|
sourceURLRequired: "请先填写来源文章 URL",
|
||||||
|
submitError: "仿写任务提交失败",
|
||||||
|
backToList: "返回仿写列表",
|
||||||
|
},
|
||||||
|
},
|
||||||
freeCreate: {
|
freeCreate: {
|
||||||
actions: {
|
actions: {
|
||||||
create: "新建文章",
|
create: "新建文章",
|
||||||
@@ -863,6 +940,7 @@ const zhCN = {
|
|||||||
templateType: "模版类型",
|
templateType: "模版类型",
|
||||||
currentVersion: "当前版本",
|
currentVersion: "当前版本",
|
||||||
source: "生成来源",
|
source: "生成来源",
|
||||||
|
sourceArticle: "源文链接",
|
||||||
wordCount: "文章字数",
|
wordCount: "文章字数",
|
||||||
},
|
},
|
||||||
versionUntitled: "未命名版本",
|
versionUntitled: "未命名版本",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
TranslationOutlined,
|
TranslationOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { computed } from "vue";
|
import { computed, type Component } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
@@ -39,6 +39,23 @@ const selectedKeys = computed(() => {
|
|||||||
});
|
});
|
||||||
const pageTitle = computed(() => t(String(route.meta.titleKey ?? "route.workspace.title")));
|
const pageTitle = computed(() => t(String(route.meta.titleKey ?? "route.workspace.title")));
|
||||||
|
|
||||||
|
type HeaderBreadcrumb = {
|
||||||
|
label: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: Component;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavSection = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
items: NavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
const localeOptions = computed(() => [
|
const localeOptions = computed(() => [
|
||||||
{ label: t("locale.zh-CN"), value: "zh-CN" },
|
{ label: t("locale.zh-CN"), value: "zh-CN" },
|
||||||
{ label: t("locale.en-US"), value: "en-US" },
|
{ label: t("locale.en-US"), value: "en-US" },
|
||||||
@@ -55,7 +72,7 @@ const currentLocaleLabel = computed(() => {
|
|||||||
return localeOptions.value.find((opt) => opt.value === currentLocale.value)?.label ?? "Language";
|
return localeOptions.value.find((opt) => opt.value === currentLocale.value)?.label ?? "Language";
|
||||||
});
|
});
|
||||||
|
|
||||||
const navSections = computed(() => {
|
const navSections = computed<NavSection[]>(() => {
|
||||||
const base = [
|
const base = [
|
||||||
{
|
{
|
||||||
key: "workspace",
|
key: "workspace",
|
||||||
@@ -75,7 +92,7 @@ const navSections = computed(() => {
|
|||||||
{ key: "/articles/templates", label: t("nav.templates") },
|
{ key: "/articles/templates", label: t("nav.templates") },
|
||||||
{ key: "/articles/custom", label: t("nav.custom") },
|
{ key: "/articles/custom", label: t("nav.custom") },
|
||||||
{ key: "/articles/free-create", label: t("nav.freeCreate") },
|
{ key: "/articles/free-create", label: t("nav.freeCreate") },
|
||||||
{ key: "/media", label: t("nav.media"), icon: GlobalOutlined },
|
{ key: "/articles/imitations", label: t("nav.imitation") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,6 +113,7 @@ const navSections = computed(() => {
|
|||||||
key: "contentManagement",
|
key: "contentManagement",
|
||||||
title: t("nav.contentManagement"),
|
title: t("nav.contentManagement"),
|
||||||
items: [
|
items: [
|
||||||
|
{ key: "/media", label: t("nav.media"), icon: GlobalOutlined },
|
||||||
{ key: "/knowledge", label: t("nav.knowledge"), icon: BookOutlined },
|
{ key: "/knowledge", label: t("nav.knowledge"), icon: BookOutlined },
|
||||||
{ key: "/images", label: t("nav.images"), icon: PictureOutlined },
|
{ key: "/images", label: t("nav.images"), icon: PictureOutlined },
|
||||||
],
|
],
|
||||||
@@ -124,6 +142,43 @@ const navSections = computed(() => {
|
|||||||
return base;
|
return base;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const headerBreadcrumbs = computed<HeaderBreadcrumb[]>(() => {
|
||||||
|
const currentTitle = pageTitle.value;
|
||||||
|
const navKey = route.meta.navKey === null ? null : String(route.meta.navKey ?? route.path);
|
||||||
|
|
||||||
|
if (!navKey) {
|
||||||
|
return [{ label: currentTitle }];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of navSections.value) {
|
||||||
|
const navItem = section.items.find((item) => item.key === navKey);
|
||||||
|
|
||||||
|
if (!navItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crumbs: HeaderBreadcrumb[] = [];
|
||||||
|
const pushCrumb = (crumb: HeaderBreadcrumb) => {
|
||||||
|
if (!crumb.label || crumbs.at(-1)?.label === crumb.label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crumbs.push(crumb);
|
||||||
|
};
|
||||||
|
const isNavPage = route.path === navItem.key || currentTitle === navItem.label;
|
||||||
|
|
||||||
|
pushCrumb({ label: section.title });
|
||||||
|
pushCrumb({ label: navItem.label, path: isNavPage ? undefined : navItem.key });
|
||||||
|
|
||||||
|
if (!isNavPage) {
|
||||||
|
pushCrumb({ label: currentTitle });
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ label: currentTitle }];
|
||||||
|
});
|
||||||
|
|
||||||
function go(path: string): void {
|
function go(path: string): void {
|
||||||
void router.push(path);
|
void router.push(path);
|
||||||
}
|
}
|
||||||
@@ -181,7 +236,29 @@ async function handleLogout(): Promise<void> {
|
|||||||
|
|
||||||
<a-layout class="admin-main-layout">
|
<a-layout class="admin-main-layout">
|
||||||
<a-layout-header class="admin-header">
|
<a-layout-header class="admin-header">
|
||||||
<h2 class="admin-header-title">{{ pageTitle }}</h2>
|
<nav class="admin-header-breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<span
|
||||||
|
v-for="(crumb, index) in headerBreadcrumbs"
|
||||||
|
:key="`${crumb.label}-${index}`"
|
||||||
|
class="breadcrumb-item"
|
||||||
|
>
|
||||||
|
<span v-if="index > 0" class="breadcrumb-separator">/</span>
|
||||||
|
<router-link
|
||||||
|
v-if="crumb.path && index < headerBreadcrumbs.length - 1"
|
||||||
|
:to="crumb.path"
|
||||||
|
class="breadcrumb-link"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
</router-link>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="breadcrumb-text"
|
||||||
|
:class="{ 'breadcrumb-text--current': index === headerBreadcrumbs.length - 1 }"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="admin-header-actions">
|
<div class="admin-header-actions">
|
||||||
<div class="admin-locale-switch">
|
<div class="admin-locale-switch">
|
||||||
@@ -296,13 +373,55 @@ async function handleLogout(): Promise<void> {
|
|||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-header-title {
|
.admin-header-breadcrumb {
|
||||||
margin: 0;
|
min-width: 0;
|
||||||
font-size: 20px;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0 9px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link,
|
||||||
|
.breadcrumb-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-text--current {
|
||||||
|
color: #141414;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-header-actions {
|
.admin-header-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
ArticleListResponse,
|
ArticleListResponse,
|
||||||
CreateArticleRequest,
|
CreateArticleRequest,
|
||||||
ArticleVersion,
|
ArticleVersion,
|
||||||
|
GenerateImitationRequest,
|
||||||
|
GenerateImitationResponse,
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
Brand,
|
Brand,
|
||||||
BrandLibrarySummary,
|
BrandLibrarySummary,
|
||||||
@@ -559,6 +561,12 @@ export const articlesApi = {
|
|||||||
create(payload: CreateArticleRequest = {}) {
|
create(payload: CreateArticleRequest = {}) {
|
||||||
return apiClient.post<ArticleDetail, CreateArticleRequest>("/api/tenant/articles", payload);
|
return apiClient.post<ArticleDetail, CreateArticleRequest>("/api/tenant/articles", payload);
|
||||||
},
|
},
|
||||||
|
generateImitation(payload: GenerateImitationRequest) {
|
||||||
|
return apiClient.post<GenerateImitationResponse, GenerateImitationRequest>(
|
||||||
|
"/api/tenant/articles/imitations/generate",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
},
|
||||||
detail(id: number) {
|
detail(id: number) {
|
||||||
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`);
|
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generateStatus === "draft" || generateStatus === "failed") {
|
if (generateStatus === "draft") {
|
||||||
return {
|
return {
|
||||||
showPublish: false,
|
showPublish: false,
|
||||||
showEdit: true,
|
showEdit: true,
|
||||||
@@ -29,6 +29,16 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generateStatus === "failed") {
|
||||||
|
return {
|
||||||
|
showPublish: false,
|
||||||
|
showEdit: false,
|
||||||
|
showPreview: false,
|
||||||
|
showCopy: false,
|
||||||
|
showDelete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showPublish: false,
|
showPublish: false,
|
||||||
showEdit: false,
|
showEdit: false,
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export function getSourceTypeLabel(sourceType?: string | null, generationMode?:
|
|||||||
if (sourceType === "free_create") {
|
if (sourceType === "free_create") {
|
||||||
return i18n.global.t("status.sourceType.free_create");
|
return i18n.global.t("status.sourceType.free_create");
|
||||||
}
|
}
|
||||||
|
if (sourceType === "imitation") {
|
||||||
|
return i18n.global.t("status.sourceType.imitation");
|
||||||
|
}
|
||||||
if (sourceType === "kol") {
|
if (sourceType === "kol") {
|
||||||
return i18n.global.t("status.sourceType.kol");
|
return i18n.global.t("status.sourceType.kol");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
article_generating: "文章仍在生成中",
|
article_generating: "文章仍在生成中",
|
||||||
article_not_editable: "只有已完成文章才可编辑",
|
article_not_editable: "只有已完成文章才可编辑",
|
||||||
article_lookup_failed: "文章状态读取失败",
|
article_lookup_failed: "文章状态读取失败",
|
||||||
draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑",
|
draft_not_editable: "只有草稿文章才可继续在向导中编辑",
|
||||||
article_title_required: "请输入文章标题",
|
article_title_required: "请输入文章标题",
|
||||||
invalid_image: "请选择图片文件",
|
invalid_image: "请选择图片文件",
|
||||||
image_upload_too_large: "图片不能超过 10MB",
|
image_upload_too_large: "图片不能超过 10MB",
|
||||||
|
|||||||
@@ -94,6 +94,26 @@ const router = createRouter({
|
|||||||
navKey: "/articles/free-create",
|
navKey: "/articles/free-create",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "articles/imitations",
|
||||||
|
name: "articles-imitations",
|
||||||
|
component: () => import("@/views/ImitationView.vue"),
|
||||||
|
meta: {
|
||||||
|
titleKey: "route.imitation.title",
|
||||||
|
descriptionKey: "route.imitation.description",
|
||||||
|
navKey: "/articles/imitations",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "articles/imitations/create",
|
||||||
|
name: "article-imitation-create",
|
||||||
|
component: () => import("@/views/ImitationGenerateView.vue"),
|
||||||
|
meta: {
|
||||||
|
titleKey: "route.imitationCreate.title",
|
||||||
|
descriptionKey: "route.imitationCreate.description",
|
||||||
|
navKey: "/articles/imitations",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "media",
|
path: "media",
|
||||||
name: "media",
|
name: "media",
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LeftOutlined, LinkOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import { computed, reactive, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import KnowledgeGroupSelect from "@/components/KnowledgeGroupSelect.vue";
|
||||||
|
import { articlesApi } from "@/lib/api";
|
||||||
|
import { formatError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
source_url: "",
|
||||||
|
source_title: "",
|
||||||
|
locale: "zh-CN",
|
||||||
|
industry: "",
|
||||||
|
brand_name: "",
|
||||||
|
region: "",
|
||||||
|
target_audience: "",
|
||||||
|
content_goal: "",
|
||||||
|
tone: "",
|
||||||
|
length_goal: "",
|
||||||
|
keywords: [] as string[],
|
||||||
|
preserve_points: "",
|
||||||
|
avoid_points: "",
|
||||||
|
extra_requirements: "",
|
||||||
|
enable_web_search: false,
|
||||||
|
knowledge_group_ids: [] as number[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() => form.source_url.trim().length > 0 && !generateMutation.isPending.value);
|
||||||
|
|
||||||
|
const generateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
articlesApi.generateImitation({
|
||||||
|
source_url: form.source_url.trim(),
|
||||||
|
source_title: form.source_title.trim(),
|
||||||
|
locale: form.locale,
|
||||||
|
industry: form.industry.trim(),
|
||||||
|
brand_name: form.brand_name.trim(),
|
||||||
|
region: form.region.trim(),
|
||||||
|
target_audience: form.target_audience.trim(),
|
||||||
|
content_goal: form.content_goal.trim(),
|
||||||
|
tone: form.tone.trim(),
|
||||||
|
length_goal: form.length_goal.trim(),
|
||||||
|
keywords: form.keywords.map((item) => item.trim()).filter(Boolean),
|
||||||
|
preserve_points: form.preserve_points.trim(),
|
||||||
|
avoid_points: form.avoid_points.trim(),
|
||||||
|
extra_requirements: form.extra_requirements.trim(),
|
||||||
|
enable_web_search: form.enable_web_search,
|
||||||
|
web_search_limit: form.enable_web_search ? 5 : undefined,
|
||||||
|
knowledge_group_ids: form.knowledge_group_ids,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||||
|
]);
|
||||||
|
message.info(t("imitation.create.queued"));
|
||||||
|
await router.replace({ name: "articles-imitations" });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(formatError(error) || t("imitation.create.submitError"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.source_url, route.query.source_title],
|
||||||
|
([sourceURL, sourceTitle]) => {
|
||||||
|
form.source_url = normalizeQueryValue(sourceURL);
|
||||||
|
form.source_title = normalizeQueryValue(sourceTitle);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeQueryValue(value: unknown): string {
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
|
return String(raw ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
void router.push({ name: "articles-imitations" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (!form.source_url.trim()) {
|
||||||
|
message.warning(t("imitation.create.sourceURLRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
generateMutation.mutate();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="imitation-generate-page">
|
||||||
|
<header class="imitation-generate-page__header">
|
||||||
|
<div class="imitation-generate-page__header-left">
|
||||||
|
<a-button type="text" shape="circle" @click="goBack">
|
||||||
|
<template #icon><LeftOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<div class="imitation-generate-page__title-box">
|
||||||
|
<h2>{{ t("imitation.create.title") }}</h2>
|
||||||
|
<p>{{ t("imitation.create.subtitle") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="imitation-generate-page__main">
|
||||||
|
<section class="imitation-generate-page__step">
|
||||||
|
<div class="imitation-generate-page__step-line">
|
||||||
|
<span>1</span>
|
||||||
|
<strong>{{ t("imitation.create.settingsSection") }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="imitation-generate-page__section">
|
||||||
|
<div class="imitation-generate-page__section-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ t("imitation.create.sourceSection") }}</h3>
|
||||||
|
<p>{{ t("route.imitationCreate.description") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field-grid">
|
||||||
|
<div class="imitation-generate-page__field imitation-generate-page__field--wide">
|
||||||
|
<label class="required-asterisk">{{ t("imitation.create.sourceURL") }}</label>
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.source_url"
|
||||||
|
:placeholder="t('imitation.create.sourceURLPlaceholder')"
|
||||||
|
>
|
||||||
|
<template #prefix><LinkOutlined /></template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.sourceTitle") }}</label>
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.source_title"
|
||||||
|
:placeholder="t('imitation.create.sourceTitlePlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.language") }}</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.locale"
|
||||||
|
:options="[
|
||||||
|
{ label: t('templates.wizard.localeOptions.zh'), value: 'zh-CN' },
|
||||||
|
{ label: t('templates.wizard.localeOptions.en'), value: 'en-US' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="imitation-generate-page__section">
|
||||||
|
<div class="imitation-generate-page__section-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ t("imitation.create.settingsSection") }}</h3>
|
||||||
|
<p>{{ t("templates.wizard.hints.keyPoints") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__capability-card">
|
||||||
|
<div class="imitation-generate-page__capability-row">
|
||||||
|
<div class="imitation-generate-page__capability-copy">
|
||||||
|
<div class="imitation-generate-page__capability-label">
|
||||||
|
{{ t("imitation.create.enableWebSearch") }}
|
||||||
|
</div>
|
||||||
|
<div class="imitation-generate-page__capability-hint">
|
||||||
|
{{ t("imitation.create.webSearchHint") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="form.enable_web_search"
|
||||||
|
:checked-children="t('common.yes')"
|
||||||
|
:un-checked-children="t('common.no')"
|
||||||
|
:disabled="generateMutation.isPending.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__capability-knowledge">
|
||||||
|
<label class="imitation-generate-page__capability-label">
|
||||||
|
{{ t("imitation.create.knowledgeBase") }}
|
||||||
|
</label>
|
||||||
|
<KnowledgeGroupSelect
|
||||||
|
v-model="form.knowledge_group_ids"
|
||||||
|
:disabled="generateMutation.isPending.value"
|
||||||
|
:placeholder="t('imitation.create.knowledgeBasePlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="imitation-generate-page__capability-hint">
|
||||||
|
{{ t("imitation.create.knowledgeBaseHint") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field-grid">
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.industry") }}</label>
|
||||||
|
<a-input v-model:value="form.industry" :placeholder="t('imitation.create.industryPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.brandName") }}</label>
|
||||||
|
<a-input v-model:value="form.brand_name" :placeholder="t('imitation.create.brandNamePlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.region") }}</label>
|
||||||
|
<a-input v-model:value="form.region" :placeholder="t('imitation.create.regionPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.targetAudience") }}</label>
|
||||||
|
<a-input v-model:value="form.target_audience" :placeholder="t('imitation.create.targetAudiencePlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.contentGoal") }}</label>
|
||||||
|
<a-input v-model:value="form.content_goal" :placeholder="t('imitation.create.contentGoalPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.tone") }}</label>
|
||||||
|
<a-input v-model:value="form.tone" :placeholder="t('imitation.create.tonePlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.lengthGoal") }}</label>
|
||||||
|
<a-input v-model:value="form.length_goal" :placeholder="t('imitation.create.lengthGoalPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field imitation-generate-page__field--wide">
|
||||||
|
<label>{{ t("imitation.create.keywords") }}</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.keywords"
|
||||||
|
mode="tags"
|
||||||
|
style="width: 100%"
|
||||||
|
:placeholder="t('imitation.create.keywordsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.preservePoints") }}</label>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.preserve_points"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="t('imitation.create.preservePointsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field">
|
||||||
|
<label>{{ t("imitation.create.avoidPoints") }}</label>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.avoid_points"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="t('imitation.create.avoidPointsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-generate-page__field imitation-generate-page__field--wide">
|
||||||
|
<label>{{ t("imitation.create.extraRequirements") }}</label>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.extra_requirements"
|
||||||
|
:rows="5"
|
||||||
|
:placeholder="t('imitation.create.extraRequirementsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="imitation-generate-page__footer">
|
||||||
|
<a-button @click="goBack">{{ t("imitation.create.backToList") }}</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="generateMutation.isPending.value"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ t("imitation.actions.submit") }}
|
||||||
|
</a-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.imitation-generate-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__header {
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__title-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__title-box h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__title-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6d7c94;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__main {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1160px;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 32px 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__step {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__step-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__step-line::before,
|
||||||
|
.imitation-generate-page__step-line::after {
|
||||||
|
width: min(320px, 28vw);
|
||||||
|
height: 1px;
|
||||||
|
background: #dbe4ef;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__step-line span {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1677ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__step-line strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__section {
|
||||||
|
padding: 32px 0;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__section-head h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__section-head h3::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #111827;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__section-head p {
|
||||||
|
margin: 6px 0 0 16px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e6edf5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-label {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-knowledge {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__field--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__field > label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__field > label::after {
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-asterisk::before {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ff4d4f;
|
||||||
|
content: "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.imitation-generate-page__field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__capability-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-generate-page__step-line::before,
|
||||||
|
.imitation-generate-page__step-line::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LinkOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import type { ArticleListItem, ArticleListParams } from "@geo/shared-types";
|
||||||
|
import type { TableColumnsType } from "ant-design-vue";
|
||||||
|
import type { Dayjs } from "dayjs";
|
||||||
|
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import ArticleActionGroup from "@/components/ArticleActionGroup.vue";
|
||||||
|
import ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
|
||||||
|
import ArticleGenerateStatus from "@/components/ArticleGenerateStatus.vue";
|
||||||
|
import ArticlePublishStatus from "@/components/ArticlePublishStatus.vue";
|
||||||
|
import ArticleSourceMeta from "@/components/ArticleSourceMeta.vue";
|
||||||
|
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||||
|
import { articlesApi } from "@/lib/api";
|
||||||
|
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
|
||||||
|
import { getGenerateStatusMeta, getPublishStatusMeta } from "@/lib/display";
|
||||||
|
import { formatError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const publishModalOpen = ref(false);
|
||||||
|
const selectedPublishArticleId = ref<number | null>(null);
|
||||||
|
const articleDrawerOpen = ref(false);
|
||||||
|
const selectedArticleId = ref<number | null>(null);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const createdRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||||
|
|
||||||
|
const draftFilters = reactive<{
|
||||||
|
publish_status?: string;
|
||||||
|
generate_status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
created_from?: string;
|
||||||
|
created_to?: string;
|
||||||
|
}>({
|
||||||
|
keyword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const appliedFilters = reactive<{
|
||||||
|
publish_status?: string;
|
||||||
|
generate_status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
created_from?: string;
|
||||||
|
created_to?: string;
|
||||||
|
}>({
|
||||||
|
keyword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const articleParams = computed<ArticleListParams>(() => {
|
||||||
|
const params: ArticleListParams = {
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
source_type: "imitation",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (appliedFilters.publish_status) {
|
||||||
|
params.publish_status = appliedFilters.publish_status;
|
||||||
|
}
|
||||||
|
if (appliedFilters.generate_status) {
|
||||||
|
params.generate_status = appliedFilters.generate_status;
|
||||||
|
}
|
||||||
|
if (appliedFilters.keyword?.trim()) {
|
||||||
|
params.keyword = appliedFilters.keyword.trim();
|
||||||
|
}
|
||||||
|
if (appliedFilters.created_from) {
|
||||||
|
params.created_from = appliedFilters.created_from;
|
||||||
|
}
|
||||||
|
if (appliedFilters.created_to) {
|
||||||
|
params.created_to = appliedFilters.created_to;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
});
|
||||||
|
|
||||||
|
const articleListQuery = useQuery({
|
||||||
|
queryKey: computed(() => ["articles", "list", articleParams.value]),
|
||||||
|
queryFn: () => articlesApi.list(articleParams.value),
|
||||||
|
});
|
||||||
|
|
||||||
|
let articlePollingTimer: number | null = null;
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (articleId: number) => articlesApi.remove(articleId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
message.success(t("imitation.list.deleteSuccess"));
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(formatError(error) || t("imitation.list.deleteError"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateStatusOptions = computed(() => [
|
||||||
|
{ label: getGenerateStatusMeta("generating").label, value: "generating" },
|
||||||
|
{ label: getGenerateStatusMeta("completed").label, value: "completed" },
|
||||||
|
{ label: getGenerateStatusMeta("failed").label, value: "failed" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const publishStatusOptions = computed(() => [
|
||||||
|
{ label: getPublishStatusMeta("unpublished").label, value: "unpublished" },
|
||||||
|
{ label: getPublishStatusMeta("publishing").label, value: "publishing" },
|
||||||
|
{ label: getPublishStatusMeta("success").label, value: "success" },
|
||||||
|
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
||||||
|
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||||
|
{
|
||||||
|
title: t("common.title"),
|
||||||
|
dataIndex: "title",
|
||||||
|
key: "title",
|
||||||
|
width: 460,
|
||||||
|
className: "imitation-view__title-column",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("imitation.list.sourceArticle"),
|
||||||
|
dataIndex: "source_url",
|
||||||
|
key: "source",
|
||||||
|
width: 440,
|
||||||
|
className: "imitation-view__source-column",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.generateStatus"),
|
||||||
|
dataIndex: "generate_status",
|
||||||
|
key: "generate_status",
|
||||||
|
width: 128,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.publishStatus"),
|
||||||
|
dataIndex: "publish_status",
|
||||||
|
key: "publish_status",
|
||||||
|
width: 128,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.wordCount"),
|
||||||
|
dataIndex: "word_count",
|
||||||
|
key: "word_count",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.actions"),
|
||||||
|
key: "actions",
|
||||||
|
width: 156,
|
||||||
|
fixed: "right",
|
||||||
|
align: "right",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resolveImitationSourceTitle(article: ArticleListItem): string {
|
||||||
|
return article.source_title?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImitationSourceURL(article: ArticleListItem): string {
|
||||||
|
return article.source_url?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => articleListQuery.data.value?.items || [],
|
||||||
|
(items: ArticleListItem[]) => {
|
||||||
|
const hasGeneratingArticles = items.some((item) =>
|
||||||
|
item.generate_status === "queued" ||
|
||||||
|
item.generate_status === "pending" ||
|
||||||
|
item.generate_status === "generating" ||
|
||||||
|
item.generate_status === "running",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasGeneratingArticles) {
|
||||||
|
startArticlePolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopArticlePolling();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopArticlePolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilters(): void {
|
||||||
|
page.value = 1;
|
||||||
|
appliedFilters.publish_status = draftFilters.publish_status;
|
||||||
|
appliedFilters.generate_status = draftFilters.generate_status;
|
||||||
|
appliedFilters.keyword = draftFilters.keyword?.trim() || "";
|
||||||
|
appliedFilters.created_from = createdRange.value?.[0]?.toISOString();
|
||||||
|
appliedFilters.created_to = createdRange.value?.[1]?.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftFilters.publish_status = undefined;
|
||||||
|
draftFilters.generate_status = undefined;
|
||||||
|
draftFilters.keyword = "";
|
||||||
|
createdRange.value = null;
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
void router.push({ name: "article-imitation-create" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(article: ArticleListItem): void {
|
||||||
|
void router.push({ name: "article-editor", params: { id: String(article.id) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPublish(article: ArticleListItem): void {
|
||||||
|
selectedPublishArticleId.value = article.id;
|
||||||
|
publishModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(article: ArticleListItem): void {
|
||||||
|
selectedArticleId.value = article.id;
|
||||||
|
articleDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableChange(nextPage: number, nextPageSize: number): void {
|
||||||
|
page.value = nextPage;
|
||||||
|
pageSize.value = nextPageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startArticlePolling(): void {
|
||||||
|
if (articlePollingTimer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
articlePollingTimer = window.setInterval(() => {
|
||||||
|
void articleListQuery.refetch();
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopArticlePolling(): void {
|
||||||
|
if (articlePollingTimer === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearInterval(articlePollingTimer);
|
||||||
|
articlePollingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(articleId: number): Promise<void> {
|
||||||
|
await deleteMutation.mutateAsync(articleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyArticle(articleId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const detail = await articlesApi.detail(articleId);
|
||||||
|
const content = buildArticleClipboardContent(detail);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
message.warning(t("common.noData"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
message.success(t("common.copySuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatError(error) || t("common.noData"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="imitation-view">
|
||||||
|
<section class="imitation-view__top-card">
|
||||||
|
<div class="imitation-view__header">
|
||||||
|
<div class="imitation-view__header-title">
|
||||||
|
<h2>{{ t("route.imitation.title") }}</h2>
|
||||||
|
<p>{{ t("route.imitation.description") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="imitation-view__header-actions">
|
||||||
|
<a-button type="primary" @click="openCreate">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
{{ t("imitation.actions.create") }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider style="margin: 0; border-color: #f0f0f0;" />
|
||||||
|
|
||||||
|
<div class="imitation-view__filters">
|
||||||
|
<div class="imitation-view__filters-inline">
|
||||||
|
<div class="imitation-view__filter-item">
|
||||||
|
<label>{{ t("imitation.filters.publishStatus") }}:</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="draftFilters.publish_status"
|
||||||
|
allow-clear
|
||||||
|
:options="publishStatusOptions"
|
||||||
|
:placeholder="t('common.selectPlease')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-view__filter-item">
|
||||||
|
<label>{{ t("imitation.filters.generateStatus") }}:</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="draftFilters.generate_status"
|
||||||
|
allow-clear
|
||||||
|
:options="generateStatusOptions"
|
||||||
|
:placeholder="t('common.selectPlease')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-view__filter-item">
|
||||||
|
<label>{{ t("imitation.filters.createdTime") }}:</label>
|
||||||
|
<a-range-picker
|
||||||
|
v-model:value="createdRange"
|
||||||
|
allow-clear
|
||||||
|
show-time
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
:placeholder="[
|
||||||
|
t('imitation.filters.createdTimeStart'),
|
||||||
|
t('imitation.filters.createdTimeEnd'),
|
||||||
|
]"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imitation-view__filter-item">
|
||||||
|
<label>{{ t("imitation.filters.keyword") }}:</label>
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="draftFilters.keyword"
|
||||||
|
:placeholder="t('imitation.filters.keywordPlaceholder')"
|
||||||
|
@search="applyFilters"
|
||||||
|
@pressEnter="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-button class="imitation-view__reset-btn" @click="resetFilters">{{ t("common.reset") }}</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="imitation-view__table-card">
|
||||||
|
<div class="imitation-view__table-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ t("imitation.list.eyebrow") }}</p>
|
||||||
|
<h3>{{ t("imitation.list.count", { count: articleListQuery.data.value?.total ?? 0 }) }}</h3>
|
||||||
|
</div>
|
||||||
|
<a-button type="link" @click="articleListQuery.refetch()">
|
||||||
|
{{ t("common.refresh") }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="articleColumns"
|
||||||
|
:data-source="articleListQuery.data.value?.items || []"
|
||||||
|
:loading="articleListQuery.isPending.value"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 1420 }"
|
||||||
|
:pagination="{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: articleListQuery.data.value?.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: handleTableChange,
|
||||||
|
onShowSizeChange: handleTableChange,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #emptyText>{{ t("imitation.list.empty") }}</template>
|
||||||
|
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'title'">
|
||||||
|
<div class="imitation-view__title-cell">
|
||||||
|
<strong>{{ record.title || t("article.untitled") }}</strong>
|
||||||
|
<ArticleSourceMeta
|
||||||
|
:source-type="record.source_type"
|
||||||
|
:generation-mode="record.generation_mode"
|
||||||
|
:created-at="record.created_at"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'source'">
|
||||||
|
<div
|
||||||
|
v-if="resolveImitationSourceTitle(record) || resolveImitationSourceURL(record)"
|
||||||
|
class="imitation-view__source-cell"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="resolveImitationSourceURL(record)"
|
||||||
|
class="imitation-view__source-link"
|
||||||
|
:href="resolveImitationSourceURL(record)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="resolveImitationSourceURL(record)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<LinkOutlined />
|
||||||
|
<span class="imitation-view__source-copy">
|
||||||
|
<span v-if="resolveImitationSourceTitle(record)" class="imitation-view__source-title">
|
||||||
|
{{ resolveImitationSourceTitle(record) }}
|
||||||
|
</span>
|
||||||
|
<span class="imitation-view__source-url">{{ resolveImitationSourceURL(record) }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<span v-else class="imitation-view__source-copy">
|
||||||
|
<span class="imitation-view__source-title">{{ resolveImitationSourceTitle(record) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else>--</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'generate_status'">
|
||||||
|
<ArticleGenerateStatus :status="record.generate_status" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'publish_status'">
|
||||||
|
<ArticlePublishStatus
|
||||||
|
:status="record.publish_status"
|
||||||
|
:article-id="record.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'word_count'">
|
||||||
|
{{ record.word_count || "--" }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<ArticleActionGroup
|
||||||
|
v-bind="resolveArticleActionState(record.generate_status)"
|
||||||
|
:delete-confirm-title="t('imitation.list.deleteConfirm')"
|
||||||
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
|
@publish="openPublish(record)"
|
||||||
|
@edit="openEditor(record)"
|
||||||
|
@preview="openPreview(record)"
|
||||||
|
@copy="copyArticle(record.id)"
|
||||||
|
@delete="handleDelete(record.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PublishArticleModal
|
||||||
|
v-model:open="publishModalOpen"
|
||||||
|
:article-id="selectedPublishArticleId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArticleDetailDrawer
|
||||||
|
:open="articleDrawerOpen"
|
||||||
|
:article-id="selectedArticleId"
|
||||||
|
@close="articleDrawerOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.imitation-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__top-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e6edf5;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__header-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__header-title p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filters {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filters-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filter-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filter-item label {
|
||||||
|
color: #101828;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filter-item :deep(.ant-select) {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filter-item :deep(.ant-picker) {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__filter-item :deep(.ant-input-affix-wrapper),
|
||||||
|
.imitation-view__filter-item :deep(.ant-input-search) {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__reset-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__table-card {
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #e6edf5;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__table-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__table-head h3 {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__title-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__title-cell > strong {
|
||||||
|
color: #101828;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.imitation-view__title-column),
|
||||||
|
:deep(.imitation-view__source-column) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-link {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #5b6f8f;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-link :deep(.anticon) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-link:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-title,
|
||||||
|
.imitation-view__source-url {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-title {
|
||||||
|
color: #101828;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imitation-view__source-url {
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.imitation-view__filter-item :deep(.ant-picker) {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.imitation-view__table-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -52,40 +52,44 @@ function handleCardClick(id: number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="kol-marketplace">
|
<div class="kol-marketplace">
|
||||||
<div class="page-header">
|
<section class="kol-marketplace__top-card">
|
||||||
<div class="header-content">
|
<div class="kol-marketplace__header">
|
||||||
<h1>{{ t("kol.marketplace.title") }}</h1>
|
<div class="kol-marketplace__header-title">
|
||||||
<p class="description">{{ t("route.templates.description") }}</p>
|
<h2>{{ t("kol.marketplace.title") }}</h2>
|
||||||
|
<p>{{ t("route.templates.description") }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters-card">
|
<a-divider style="margin: 0; border-color: #f0f0f0;" />
|
||||||
<div class="filter-item">
|
|
||||||
<label>{{ t("kol.marketplace.filter.industry") }}</label>
|
<div class="kol-marketplace__filters">
|
||||||
<a-select
|
<div class="kol-marketplace__filters-inline">
|
||||||
v-model:value="filters.industry"
|
<div class="kol-marketplace__filter-item">
|
||||||
style="width: 160px"
|
<label>{{ t("kol.marketplace.filter.industry") }}:</label>
|
||||||
:options="industryOptions"
|
<a-select
|
||||||
:placeholder="t('common.selectPlease')"
|
v-model:value="filters.industry"
|
||||||
allow-clear
|
:options="industryOptions"
|
||||||
/>
|
:placeholder="t('common.selectPlease')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="kol-marketplace__filter-item">
|
||||||
|
<label>{{ t("kol.marketplace.filter.keyword") }}:</label>
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="filters.keyword"
|
||||||
|
:placeholder="t('common.search')"
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<template #enterButton>
|
||||||
|
<a-button type="primary">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input-search>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item">
|
</section>
|
||||||
<label>{{ t("kol.marketplace.filter.keyword") }}</label>
|
|
||||||
<a-input-search
|
|
||||||
v-model:value="filters.keyword"
|
|
||||||
style="width: 280px"
|
|
||||||
:placeholder="t('common.search')"
|
|
||||||
allow-clear
|
|
||||||
>
|
|
||||||
<template #enterButton>
|
|
||||||
<a-button type="primary">
|
|
||||||
<template #icon><SearchOutlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
</a-input-search>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="marketplace-grid" v-if="marketplaceQuery.data.value?.length">
|
<div class="marketplace-grid" v-if="marketplaceQuery.data.value?.length">
|
||||||
<a-row :gutter="[24, 24]">
|
<a-row :gutter="[24, 24]">
|
||||||
@@ -116,51 +120,70 @@ function handleCardClick(id: number) {
|
|||||||
.kol-marketplace {
|
.kol-marketplace {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.kol-marketplace__top-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6edf5;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e6edf5;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content h1 {
|
.kol-marketplace__header-title h2 {
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
color: #8c8c8c;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
.filters-card {
|
|
||||||
padding: 20px 24px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e6edf5;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 32px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-item label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__header-title p {
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__filters {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__filters-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__filter-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__filter-item label {
|
||||||
|
color: #101828;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__filter-item :deep(.ant-select) {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kol-marketplace__filter-item :deep(.ant-input-affix-wrapper),
|
||||||
|
.kol-marketplace__filter-item :deep(.ant-input-search) {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.marketplace-grid {
|
.marketplace-grid {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -173,14 +196,8 @@ function handleCardClick(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.filters-card {
|
.kol-marketplace__filter-item :deep(.ant-select),
|
||||||
flex-direction: column;
|
.kol-marketplace__filter-item :deep(.ant-input-search) {
|
||||||
align-items: stretch;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-item :deep(.ant-select),
|
|
||||||
.filter-item :deep(.ant-input-search) {
|
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetWizardState(detail);
|
resetWizardState(detail);
|
||||||
if (draftArticle && (draftArticle.generate_status === "draft" || draftArticle.generate_status === "failed")) {
|
if (draftArticle && draftArticle.generate_status === "draft") {
|
||||||
applyDraftArticleState(draftArticle);
|
applyDraftArticleState(draftArticle);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ async function openEditor(article: ArticleListItem): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const detail = await articlesApi.detail(article.id);
|
const detail = await articlesApi.detail(article.id);
|
||||||
|
|
||||||
if (detail.generate_status !== "completed" && detail.source_type === "template" && detail.template_id) {
|
if (detail.generate_status === "draft" && detail.source_type === "template" && detail.template_id) {
|
||||||
await router.push({
|
await router.push({
|
||||||
name: "article-wizard",
|
name: "article-wizard",
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeftOutlined } from "@ant-design/icons-vue";
|
import { ArrowLeftOutlined, CopyOutlined } from "@ant-design/icons-vue";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type {
|
import type {
|
||||||
@@ -194,6 +194,29 @@ function goBack(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openExternalURL(url: string | null | undefined): void {
|
||||||
|
const normalized = String(url ?? "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(normalized, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImitationCreate(sourceURL: string | null | undefined, sourceTitle?: string | null): void {
|
||||||
|
const normalizedURL = String(sourceURL ?? "").trim();
|
||||||
|
if (!normalizedURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({
|
||||||
|
name: "article-imitation-create",
|
||||||
|
query: {
|
||||||
|
source_url: normalizedURL,
|
||||||
|
source_title: String(sourceTitle ?? "").trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatPercent(value: number | null | undefined): string {
|
function formatPercent(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return "--";
|
return "--";
|
||||||
@@ -1025,10 +1048,11 @@ const citationAnalysisColumns = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const contentCitationColumns = computed(() => [
|
const contentCitationColumns = computed(() => [
|
||||||
{ title: t("tracking.columns.contentName"), key: "contentName", dataIndex: "contentName", width: "40%" },
|
{ title: t("tracking.columns.contentName"), key: "contentName", dataIndex: "contentName", width: "38%" },
|
||||||
{ title: t("tracking.columns.channelName"), key: "channelName", dataIndex: "channelName", width: "20%", align: "center" as const },
|
{ title: t("tracking.columns.channelName"), key: "channelName", dataIndex: "channelName", width: "20%", align: "center" as const },
|
||||||
{ title: t("tracking.columns.citations"), key: "citationCount", dataIndex: "citationCount", align: "center" as const, width: "20%" },
|
{ title: t("tracking.columns.citations"), key: "citationCount", dataIndex: "citationCount", align: "center" as const, width: "16%" },
|
||||||
{ title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citationRate", align: "center" as const, width: "20%" },
|
{ title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citationRate", align: "center" as const, width: "16%" },
|
||||||
|
{ title: t("common.actions"), key: "actions", align: "center" as const, width: "10%" },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1138,15 +1162,16 @@ const contentCitationColumns = computed(() => [
|
|||||||
|
|
||||||
<div ref="citationSourcesScrollRef" class="tracking-question-card__scroll-area custom-scrollbar">
|
<div ref="citationSourcesScrollRef" class="tracking-question-card__scroll-area custom-scrollbar">
|
||||||
<div v-if="activePlatform?.citations?.length" class="tracking-question-source-list">
|
<div v-if="activePlatform?.citations?.length" class="tracking-question-source-list">
|
||||||
<a
|
<div
|
||||||
v-for="(citation, index) in activePlatform.citations"
|
v-for="(citation, index) in activePlatform.citations"
|
||||||
:key="citation.cited_url"
|
:key="citation.cited_url"
|
||||||
:href="citation.cited_url"
|
role="link"
|
||||||
target="_blank"
|
tabindex="0"
|
||||||
rel="noreferrer"
|
|
||||||
class="tracking-question-source-card"
|
class="tracking-question-source-card"
|
||||||
:class="{ 'is-linked': highlightedCitationIndex === index + 1 }"
|
:class="{ 'is-linked': highlightedCitationIndex === index + 1 }"
|
||||||
:data-citation-index="index + 1"
|
:data-citation-index="index + 1"
|
||||||
|
@click="openExternalURL(citation.cited_url)"
|
||||||
|
@keydown.enter="openExternalURL(citation.cited_url)"
|
||||||
>
|
>
|
||||||
<div class="tracking-question-source-card__headline">
|
<div class="tracking-question-source-card__headline">
|
||||||
<div class="tracking-question-source-card__indexes">
|
<div class="tracking-question-source-card__indexes">
|
||||||
@@ -1168,6 +1193,15 @@ const contentCitationColumns = computed(() => [
|
|||||||
<strong>{{ citation.site_name }}</strong>
|
<strong>{{ citation.site_name }}</strong>
|
||||||
<span class="tracking-question-source-card__divider" v-if="resolveCitationTitle(citation)">·</span>
|
<span class="tracking-question-source-card__divider" v-if="resolveCitationTitle(citation)">·</span>
|
||||||
<span class="tracking-question-source-card__title" :title="resolveCitationTitle(citation)">{{ resolveCitationTitle(citation) }}</span>
|
<span class="tracking-question-source-card__title" :title="resolveCitationTitle(citation)">{{ resolveCitationTitle(citation) }}</span>
|
||||||
|
<a-tooltip :title="t('tracking.imitationAction')">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tracking-question-imitation-trigger tracking-question-imitation-trigger--source"
|
||||||
|
@click.stop="openImitationCreate(citation.cited_url, resolveCitationTitle(citation))"
|
||||||
|
>
|
||||||
|
<CopyOutlined />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
<a-tag
|
<a-tag
|
||||||
v-if="citation.article_id && supportsContentCitationDisplay(activePlatform?.ai_platform_id)"
|
v-if="citation.article_id && supportsContentCitationDisplay(activePlatform?.ai_platform_id)"
|
||||||
color="blue"
|
color="blue"
|
||||||
@@ -1179,7 +1213,7 @@ const contentCitationColumns = computed(() => [
|
|||||||
<div class="tracking-question-source-card__link">
|
<div class="tracking-question-source-card__link">
|
||||||
{{ citation.cited_url }}
|
{{ citation.cited_url }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-empty v-else :description="t('tracking.noCitations')" />
|
<a-empty v-else :description="t('tracking.noCitations')" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1279,6 +1313,18 @@ const contentCitationColumns = computed(() => [
|
|||||||
<template v-else-if="column.key === 'citationRate'">
|
<template v-else-if="column.key === 'citationRate'">
|
||||||
<strong class="metric-highlight">{{ formatPercent(record.citationRate) }}</strong>
|
<strong class="metric-highlight">{{ formatPercent(record.citationRate) }}</strong>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-tooltip v-if="record.citedURL" :title="t('tracking.imitationAction')">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tracking-question-imitation-trigger tracking-question-imitation-trigger--table-action"
|
||||||
|
@click.stop="openImitationCreate(record.citedURL, record.contentName)"
|
||||||
|
>
|
||||||
|
<CopyOutlined />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-else class="tracking-question-table__empty-action">--</span>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1546,6 +1592,7 @@ const contentCitationColumns = computed(() => [
|
|||||||
|
|
||||||
.tracking-question-source-card,
|
.tracking-question-source-card,
|
||||||
.tracking-question-content-card {
|
.tracking-question-content-card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -1553,6 +1600,7 @@ const contentCitationColumns = computed(() => [
|
|||||||
border: 1px solid #edf2f7;
|
border: 1px solid #edf2f7;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -1563,6 +1611,10 @@ const contentCitationColumns = computed(() => [
|
|||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-question-source-card {
|
||||||
|
padding-right: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
.tracking-question-source-card.is-linked {
|
.tracking-question-source-card.is-linked {
|
||||||
border-color: #93c5fd;
|
border-color: #93c5fd;
|
||||||
background: #eef6ff;
|
background: #eef6ff;
|
||||||
@@ -1649,6 +1701,62 @@ const contentCitationColumns = computed(() => [
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-question-imitation-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(1px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-question-imitation-trigger:hover {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-question-imitation-trigger--source {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 16px;
|
||||||
|
transform: translateY(calc(-50% + 1px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-question-source-card:hover .tracking-question-imitation-trigger,
|
||||||
|
.tracking-question-source-card:focus-within .tracking-question-imitation-trigger {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-question-source-card:hover .tracking-question-imitation-trigger--source,
|
||||||
|
.tracking-question-source-card:focus-within .tracking-question-imitation-trigger--source {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-question-imitation-trigger--table-action {
|
||||||
|
margin: 0 auto;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-question-table__empty-action {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.tracking-question-source-card__link {
|
.tracking-question-source-card__link {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ async function openEditor(article: RecentArticle): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const detail = await articlesApi.detail(article.id);
|
const detail = await articlesApi.detail(article.id);
|
||||||
|
|
||||||
if (detail.generate_status !== "completed" && detail.source_type === "template" && detail.template_id) {
|
if (detail.generate_status === "draft" && detail.source_type === "template" && detail.template_id) {
|
||||||
await router.push({
|
await router.push({
|
||||||
name: "article-wizard",
|
name: "article-wizard",
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ export interface ArticleListItem {
|
|||||||
generation_error_message: string | null;
|
generation_error_message: string | null;
|
||||||
word_count: number;
|
word_count: number;
|
||||||
source_label: string | null;
|
source_label: string | null;
|
||||||
|
source_url?: string | null;
|
||||||
|
source_title?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +530,8 @@ export interface ArticleDetail {
|
|||||||
markdown_content: string | null;
|
markdown_content: string | null;
|
||||||
word_count: number;
|
word_count: number;
|
||||||
source_label: string | null;
|
source_label: string | null;
|
||||||
|
source_url?: string | null;
|
||||||
|
source_title?: string | null;
|
||||||
version_no: number | null;
|
version_no: number | null;
|
||||||
generation_error_message: string | null;
|
generation_error_message: string | null;
|
||||||
wizard_state?: Record<string, JsonValue> | null;
|
wizard_state?: Record<string, JsonValue> | null;
|
||||||
@@ -538,6 +542,31 @@ export interface CreateArticleRequest {
|
|||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateImitationRequest {
|
||||||
|
source_url: string;
|
||||||
|
source_title?: string;
|
||||||
|
locale?: string;
|
||||||
|
industry?: string;
|
||||||
|
brand_name?: string;
|
||||||
|
region?: string;
|
||||||
|
target_audience?: string;
|
||||||
|
content_goal?: string;
|
||||||
|
tone?: string;
|
||||||
|
length_goal?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
preserve_points?: string;
|
||||||
|
avoid_points?: string;
|
||||||
|
extra_requirements?: string;
|
||||||
|
enable_web_search?: boolean;
|
||||||
|
web_search_limit?: number;
|
||||||
|
knowledge_group_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateImitationResponse {
|
||||||
|
article_id: number;
|
||||||
|
task_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateArticleRequest {
|
export interface UpdateArticleRequest {
|
||||||
title: string;
|
title: string;
|
||||||
markdown_content: string;
|
markdown_content: string;
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ func main() {
|
|||||||
app.Config.LLM.MaxOutputTokens,
|
app.Config.LLM.MaxOutputTokens,
|
||||||
).WithCache(app.Cache)
|
).WithCache(app.Cache)
|
||||||
|
|
||||||
|
imitationSvc := tenantapp.NewArticleImitationService(
|
||||||
|
app.DB,
|
||||||
|
app.LLM,
|
||||||
|
nil,
|
||||||
|
knowledgeSvc,
|
||||||
|
app.GenerationStreams,
|
||||||
|
generationCfg,
|
||||||
|
app.Config.LLM.MaxOutputTokens,
|
||||||
|
).WithCache(app.Cache)
|
||||||
|
|
||||||
kolWorker := generateworker.NewKolGenerationWorker(
|
kolWorker := generateworker.NewKolGenerationWorker(
|
||||||
app.DB,
|
app.DB,
|
||||||
app.LLM,
|
app.LLM,
|
||||||
@@ -82,6 +92,7 @@ func main() {
|
|||||||
app.RabbitMQ,
|
app.RabbitMQ,
|
||||||
templateSvc,
|
templateSvc,
|
||||||
promptRuleSvc,
|
promptRuleSvc,
|
||||||
|
imitationSvc,
|
||||||
kolWorker,
|
kolWorker,
|
||||||
app.Logger,
|
app.Logger,
|
||||||
app.Config.Generation,
|
app.Config.Generation,
|
||||||
|
|||||||
@@ -208,6 +208,47 @@ runtime:
|
|||||||
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
|
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
|
||||||
3. 不要输出你的思考过程、解释或致歉。
|
3. 不要输出你的思考过程、解释或致歉。
|
||||||
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
|
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
|
||||||
|
article_imitation_prompt_template: |
|
||||||
|
你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
|
||||||
|
|
||||||
|
核心要求:
|
||||||
|
- 保留源文章中的事实信息、论证逻辑和可复用观点,但必须重写标题、结构、表达和段落组织。
|
||||||
|
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
|
||||||
|
- 标题也必须仿写改写:第一行必须是新的一级标题,需仿照源标题的信息焦点、受众意图和表达节奏,但不能直接复用源标题,也不能只做简单词语替换。
|
||||||
|
- 如果给出了地域、品牌、目标读者或内容目标,标题和正文都要自然贴合这些上下文。
|
||||||
|
%s
|
||||||
|
|
||||||
|
输出格式:Markdown。第一行必须是重写后的一级标题,正文结构清晰,可直接进入文章编辑器。
|
||||||
|
|
||||||
|
## 源文章
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 仿写设置
|
||||||
|
%s
|
||||||
|
|
||||||
|
如果某项设置为空,请根据源文章和行业常识自行选择最贴合的写法。
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 源文章内容
|
||||||
|
%s
|
||||||
|
|
||||||
|
请开始输出仿写后的完整文章。
|
||||||
|
article_imitation_locale_instructions:
|
||||||
|
zh-CN: "输出语言:中文简体。"
|
||||||
|
en-US: "Output language: English."
|
||||||
|
article_imitation_setting_labels:
|
||||||
|
industry: "行业/场景"
|
||||||
|
brand_name: "品牌/主体"
|
||||||
|
region: "地域"
|
||||||
|
target_audience: "目标读者"
|
||||||
|
content_goal: "内容目标"
|
||||||
|
tone: "语气风格"
|
||||||
|
length_goal: "篇幅要求"
|
||||||
|
keywords: "关键词"
|
||||||
|
preserve_points: "必须保留/强调"
|
||||||
|
avoid_points: "需要规避"
|
||||||
|
extra_requirements: "其他要求"
|
||||||
knowledge_prompt_intro_lines:
|
knowledge_prompt_intro_lines:
|
||||||
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||||
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、价格、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、价格、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
articleImitationTaskType = "imitation"
|
||||||
|
articleImitationSourceType = "imitation"
|
||||||
|
articleImitationFallbackTitle = "仿写文章生成中"
|
||||||
|
maxImitationSourceContentRunes = 24000
|
||||||
|
defaultImitationWebSearchLimit = int32(5)
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArticleImitationService struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
llm llm.Client
|
||||||
|
rabbitMQ *rabbitmq.Client
|
||||||
|
knowledge *KnowledgeService
|
||||||
|
streams *stream.GenerationHub
|
||||||
|
streamEnabled bool
|
||||||
|
articleTimeout time.Duration
|
||||||
|
maxOutputTokens int64
|
||||||
|
cache sharedcache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateImitationRequest struct {
|
||||||
|
SourceURL string `json:"source_url" binding:"required"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
BrandName string `json:"brand_name"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
TargetAudience string `json:"target_audience"`
|
||||||
|
ContentGoal string `json:"content_goal"`
|
||||||
|
Tone string `json:"tone"`
|
||||||
|
LengthGoal string `json:"length_goal"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
PreservePoints string `json:"preserve_points"`
|
||||||
|
AvoidPoints string `json:"avoid_points"`
|
||||||
|
ExtraRequirements string `json:"extra_requirements"`
|
||||||
|
EnableWebSearch bool `json:"enable_web_search"`
|
||||||
|
WebSearchLimit *int32 `json:"web_search_limit"`
|
||||||
|
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateImitationResponse struct {
|
||||||
|
ArticleID int64 `json:"article_id"`
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type articleImitationJob struct {
|
||||||
|
OperatorID int64
|
||||||
|
TenantID int64
|
||||||
|
ArticleID int64
|
||||||
|
TaskID int64
|
||||||
|
ReservationID int64
|
||||||
|
InputParams map[string]interface{}
|
||||||
|
InitialTitle string
|
||||||
|
WebSearch llm.WebSearchOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleImitationService(
|
||||||
|
pool *pgxpool.Pool,
|
||||||
|
llmClient llm.Client,
|
||||||
|
rabbitMQClient *rabbitmq.Client,
|
||||||
|
knowledge *KnowledgeService,
|
||||||
|
streams *stream.GenerationHub,
|
||||||
|
cfg config.GenerationConfig,
|
||||||
|
llmMaxOutputTokens int64,
|
||||||
|
) *ArticleImitationService {
|
||||||
|
articleTimeout := cfg.ArticleTimeout
|
||||||
|
if articleTimeout <= 0 {
|
||||||
|
articleTimeout = 8 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ArticleImitationService{
|
||||||
|
pool: pool,
|
||||||
|
llm: llmClient,
|
||||||
|
rabbitMQ: rabbitMQClient,
|
||||||
|
knowledge: knowledge,
|
||||||
|
streams: streams,
|
||||||
|
streamEnabled: cfg.StreamEnabled,
|
||||||
|
articleTimeout: articleTimeout,
|
||||||
|
maxOutputTokens: llmMaxOutputTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleImitationService) WithCache(c sharedcache.Cache) *ArticleImitationService {
|
||||||
|
s.cache = c
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImitationRequest) (*GenerateImitationResponse, error) {
|
||||||
|
actor := auth.MustActor(ctx)
|
||||||
|
if err := s.llm.Validate(); err != nil {
|
||||||
|
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURL, err := normalizeImitationSourceURL(req.SourceURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||||
|
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
||||||
|
if err != nil || balance < 1 {
|
||||||
|
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputParams := buildImitationInputParams(sourceURL, req)
|
||||||
|
inputJSON, _ := json.Marshal(inputParams)
|
||||||
|
initialTitle := buildImitationInitialTitle(req.SourceTitle)
|
||||||
|
wizardStateJSON, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"title": initialTitle,
|
||||||
|
"source_url": sourceURL,
|
||||||
|
"source_title": strings.TrimSpace(req.SourceTitle),
|
||||||
|
"current_step": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to begin generation transaction")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
quotaTx := repository.NewQuotaRepository(tx)
|
||||||
|
auditTx := repository.NewAuditRepository(tx)
|
||||||
|
|
||||||
|
newBalance := balance - 1
|
||||||
|
reason := "generation_reserve"
|
||||||
|
referenceType := "generation_task"
|
||||||
|
_, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
QuotaType: "article_generation",
|
||||||
|
Delta: -1,
|
||||||
|
BalanceAfter: newBalance,
|
||||||
|
Reason: &reason,
|
||||||
|
ReferenceType: &referenceType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to reserve generation quota")
|
||||||
|
}
|
||||||
|
|
||||||
|
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
QuotaType: "article_generation",
|
||||||
|
ResourceType: "article",
|
||||||
|
ResourceID: nil,
|
||||||
|
ReservedAmount: 1,
|
||||||
|
ExpireAt: time.Now().Add(time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to create quota reservation")
|
||||||
|
}
|
||||||
|
|
||||||
|
var articleID int64
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO articles (tenant_id, source_type, generate_status, publish_status, wizard_state_json)
|
||||||
|
VALUES ($1, $2, 'generating', 'unpublished', $3)
|
||||||
|
RETURNING id
|
||||||
|
`, actor.TenantID, articleImitationSourceType, wizardStateJSON).Scan(&articleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to create imitation article")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to update quota reservation resource")
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
ArticleID: &articleID,
|
||||||
|
QuotaReservationID: &reservationID,
|
||||||
|
TaskType: articleImitationTaskType,
|
||||||
|
InputParamsJSON: inputJSON,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to create generation task")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, response.ErrInternal(50012, "create_failed", "failed to commit generation task")
|
||||||
|
}
|
||||||
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||||
|
|
||||||
|
job := articleImitationJob{
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
ArticleID: articleID,
|
||||||
|
TaskID: taskID,
|
||||||
|
ReservationID: reservationID,
|
||||||
|
InputParams: inputParams,
|
||||||
|
InitialTitle: initialTitle,
|
||||||
|
WebSearch: llm.WebSearchOptions{
|
||||||
|
Enabled: req.EnableWebSearch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 {
|
||||||
|
job.WebSearch.Limit = *req.WebSearchLimit
|
||||||
|
} else if req.EnableWebSearch {
|
||||||
|
job.WebSearch.Limit = defaultImitationWebSearchLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{
|
||||||
|
TaskID: taskID,
|
||||||
|
TaskType: articleImitationTaskType,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
ArticleID: articleID,
|
||||||
|
}); err != nil {
|
||||||
|
s.failGeneration(context.Background(), job, "queue_enqueue", err)
|
||||||
|
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.streamEnabled && s.streams != nil {
|
||||||
|
s.streams.Start(articleID, taskID, initialTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GenerateImitationResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleImitationService) ProcessQueuedTask(ctx context.Context, task ClaimedGenerationTask) {
|
||||||
|
inputParams := cloneInputParams(task.InputParams)
|
||||||
|
job := articleImitationJob{
|
||||||
|
OperatorID: task.OperatorID,
|
||||||
|
TenantID: task.TenantID,
|
||||||
|
ArticleID: task.ArticleID,
|
||||||
|
TaskID: task.ID,
|
||||||
|
ReservationID: task.QuotaReservationID,
|
||||||
|
InputParams: inputParams,
|
||||||
|
InitialTitle: buildImitationInitialTitle(extractString(inputParams, "source_title")),
|
||||||
|
WebSearch: llm.WebSearchOptions{
|
||||||
|
Enabled: extractBool(inputParams, "enable_web_search"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if limit := extractInt(inputParams, "web_search_limit"); limit > 0 {
|
||||||
|
job.WebSearch.Limit = int32(limit)
|
||||||
|
} else if job.WebSearch.Enabled {
|
||||||
|
job.WebSearch.Limit = defaultImitationWebSearchLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
s.executeGeneration(ctx, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleImitationService) HandleTaskFailure(ctx context.Context, task ClaimedGenerationTask, stage string, taskErr error) {
|
||||||
|
job := articleImitationJob{
|
||||||
|
OperatorID: task.OperatorID,
|
||||||
|
TenantID: task.TenantID,
|
||||||
|
ArticleID: task.ArticleID,
|
||||||
|
TaskID: task.ID,
|
||||||
|
ReservationID: task.QuotaReservationID,
|
||||||
|
InputParams: cloneInputParams(task.InputParams),
|
||||||
|
InitialTitle: buildImitationInitialTitle(extractString(task.InputParams, "source_title")),
|
||||||
|
}
|
||||||
|
s.failGeneration(ctx, job, stage, taskErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleImitationService) executeGeneration(ctx context.Context, job articleImitationJob) {
|
||||||
|
now := time.Now()
|
||||||
|
title := strings.TrimSpace(job.InitialTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = articleImitationFallbackTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRepo := repository.NewAuditRepository(s.pool)
|
||||||
|
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||||
|
ID: job.TaskID,
|
||||||
|
TenantID: job.TenantID,
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
|
||||||
|
sourceURL := strings.TrimSpace(extractString(job.InputParams, "source_url"))
|
||||||
|
if sourceURL == "" {
|
||||||
|
s.failGeneration(ctx, job, "task_load", fmt.Errorf("source_url is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.knowledge == nil {
|
||||||
|
s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("knowledge service is not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := s.knowledge.parseWebsiteKnowledge(ctx, sourceURL)
|
||||||
|
if err != nil {
|
||||||
|
s.failGeneration(ctx, job, "source_fetch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceContent := truncateRunes(strings.TrimSpace(parsed.Markdown), maxImitationSourceContentRunes)
|
||||||
|
if sourceContent == "" {
|
||||||
|
sourceContent = truncateRunes(strings.TrimSpace(parsed.Text), maxImitationSourceContentRunes)
|
||||||
|
}
|
||||||
|
if sourceContent == "" {
|
||||||
|
s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("source article content is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
knowledgePrompt := ""
|
||||||
|
if knowledgeGroupIDs := extractKnowledgeGroupIDs(job.InputParams["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 {
|
||||||
|
if s.knowledge == nil {
|
||||||
|
s.failGeneration(ctx, job, "knowledge_resolve", fmt.Errorf("knowledge service is not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
|
||||||
|
ctx,
|
||||||
|
job.TenantID,
|
||||||
|
knowledgeGroupIDs,
|
||||||
|
buildImitationKnowledgeQuery(job.InputParams),
|
||||||
|
)
|
||||||
|
if resolveErr != nil {
|
||||||
|
s.failGeneration(ctx, job, "knowledge_resolve", resolveErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := buildImitationGenerationPrompt(job.InputParams, sourceContent, knowledgePrompt)
|
||||||
|
req := llm.GenerateRequest{
|
||||||
|
Prompt: prompt,
|
||||||
|
Timeout: s.articleTimeout,
|
||||||
|
MaxOutputTokens: s.maxOutputTokens,
|
||||||
|
}
|
||||||
|
if job.WebSearch.Enabled {
|
||||||
|
req.WebSearch = &job.WebSearch
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.llm.Generate(ctx, req, func(delta string) {
|
||||||
|
if s.streamEnabled && s.streams != nil && delta != "" {
|
||||||
|
s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.failGeneration(ctx, job, "llm_generate", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(result.Content)
|
||||||
|
if content == "" {
|
||||||
|
s.failGeneration(ctx, job, "llm_generate", fmt.Errorf("模型返回空内容"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title = resolveGeneratedImitationTitle(content, job.InputParams)
|
||||||
|
wordCount := estimateWordCount(content)
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.failGeneration(ctx, job, "persist_begin_tx", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rollbackGenerationTx(tx)
|
||||||
|
|
||||||
|
failWithRollback := func(stage string, err error) {
|
||||||
|
rollbackGenerationTx(tx)
|
||||||
|
s.failGeneration(ctx, job, stage, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
articleRepo := repository.NewArticleRepository(tx)
|
||||||
|
quotaRepo := repository.NewQuotaRepository(tx)
|
||||||
|
auditTx := repository.NewAuditRepository(tx)
|
||||||
|
|
||||||
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||||
|
ArticleID: job.ArticleID,
|
||||||
|
VersionNo: 1,
|
||||||
|
Title: title,
|
||||||
|
HTMLContent: "",
|
||||||
|
MarkdownContent: content,
|
||||||
|
WordCount: wordCount,
|
||||||
|
SourceLabel: result.Model,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
failWithRollback("persist_create_version", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil {
|
||||||
|
failWithRollback("persist_current_version", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil {
|
||||||
|
failWithRollback("persist_article_status", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completed := time.Now()
|
||||||
|
if err := auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||||
|
ID: job.TaskID,
|
||||||
|
TenantID: job.TenantID,
|
||||||
|
Status: "completed",
|
||||||
|
StartedAt: &now,
|
||||||
|
CompletedAt: &completed,
|
||||||
|
}); err != nil {
|
||||||
|
failWithRollback("persist_task_status", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil {
|
||||||
|
failWithRollback("confirm_reservation", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
failWithRollback("persist_commit", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||||
|
defer cancel()
|
||||||
|
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
||||||
|
|
||||||
|
if s.streamEnabled && s.streams != nil {
|
||||||
|
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleImitationService) failGeneration(ctx context.Context, job articleImitationJob, stage string, genErr error) {
|
||||||
|
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errMsg := formatGenerationFailure(stage, genErr)
|
||||||
|
now := time.Now()
|
||||||
|
title := strings.TrimSpace(job.InitialTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = articleImitationFallbackTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRepo := repository.NewAuditRepository(s.pool)
|
||||||
|
articleRepo := repository.NewArticleRepository(s.pool)
|
||||||
|
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||||
|
|
||||||
|
_ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{
|
||||||
|
ID: job.TaskID,
|
||||||
|
TenantID: job.TenantID,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorMessage: &errMsg,
|
||||||
|
CompletedAt: &now,
|
||||||
|
})
|
||||||
|
if job.ArticleID > 0 {
|
||||||
|
_ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed")
|
||||||
|
_, _ = s.pool.Exec(cleanupCtx, `
|
||||||
|
UPDATE articles
|
||||||
|
SET wizard_state_json = jsonb_set(COALESCE(wizard_state_json, '{}'::jsonb), '{title}', to_jsonb($1::text), true),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2 AND tenant_id = $3
|
||||||
|
`, title, job.ArticleID, job.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.ReservationID > 0 {
|
||||||
|
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
|
||||||
|
if balanceErr == nil {
|
||||||
|
reason := "generation_refund"
|
||||||
|
referenceType := "generation_task"
|
||||||
|
taskReferenceID := job.TaskID
|
||||||
|
_, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
|
||||||
|
TenantID: job.TenantID,
|
||||||
|
OperatorID: job.OperatorID,
|
||||||
|
QuotaType: "article_generation",
|
||||||
|
Delta: 1,
|
||||||
|
BalanceAfter: balance + 1,
|
||||||
|
Reason: &reason,
|
||||||
|
ReferenceType: &referenceType,
|
||||||
|
ReferenceID: &taskReferenceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.ArticleID > 0 {
|
||||||
|
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.streamEnabled && s.streams != nil && job.ArticleID > 0 {
|
||||||
|
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImitationSourceURL(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", response.ErrBadRequest(40001, "source_url_required", "source_url is required")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must be a valid URL")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must use http or https")
|
||||||
|
}
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildImitationInputParams(sourceURL string, req GenerateImitationRequest) map[string]interface{} {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"source_url": sourceURL,
|
||||||
|
"source_title": strings.TrimSpace(req.SourceTitle),
|
||||||
|
"locale": normalizeImitationLocale(req.Locale),
|
||||||
|
"industry": strings.TrimSpace(req.Industry),
|
||||||
|
"brand_name": strings.TrimSpace(req.BrandName),
|
||||||
|
"region": strings.TrimSpace(req.Region),
|
||||||
|
"target_audience": strings.TrimSpace(req.TargetAudience),
|
||||||
|
"content_goal": strings.TrimSpace(req.ContentGoal),
|
||||||
|
"tone": strings.TrimSpace(req.Tone),
|
||||||
|
"length_goal": strings.TrimSpace(req.LengthGoal),
|
||||||
|
"keywords": normalizeImitationKeywords(req.Keywords),
|
||||||
|
"preserve_points": strings.TrimSpace(req.PreservePoints),
|
||||||
|
"avoid_points": strings.TrimSpace(req.AvoidPoints),
|
||||||
|
"extra_requirements": strings.TrimSpace(req.ExtraRequirements),
|
||||||
|
"enable_web_search": req.EnableWebSearch,
|
||||||
|
"knowledge_group_ids": normalizeInt64IDs(req.KnowledgeGroupIDs),
|
||||||
|
}
|
||||||
|
if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 {
|
||||||
|
params["web_search_limit"] = *req.WebSearchLimit
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildImitationInitialTitle(sourceTitle string) string {
|
||||||
|
title := strings.TrimSpace(sourceTitle)
|
||||||
|
if title == "" {
|
||||||
|
return articleImitationFallbackTitle
|
||||||
|
}
|
||||||
|
return "仿写:" + title
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildImitationGenerationPrompt(params map[string]interface{}, sourceContent string, knowledgePrompt string) string {
|
||||||
|
locale := normalizeImitationLocale(extractString(params, "locale"))
|
||||||
|
sourceTitle := strings.TrimSpace(extractString(params, "source_title"))
|
||||||
|
sourceURL := strings.TrimSpace(extractString(params, "source_url"))
|
||||||
|
keywords := extractStringList(params["keywords"], 16)
|
||||||
|
knowledgePrompt = strings.TrimSpace(knowledgePrompt)
|
||||||
|
|
||||||
|
var sourceMeta strings.Builder
|
||||||
|
if sourceTitle != "" {
|
||||||
|
appendRawPromptLine(&sourceMeta, "标题", sourceTitle)
|
||||||
|
}
|
||||||
|
if sourceURL != "" {
|
||||||
|
appendRawPromptLine(&sourceMeta, "URL", sourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings strings.Builder
|
||||||
|
appendPromptLine(&settings, "industry", extractString(params, "industry"))
|
||||||
|
appendPromptLine(&settings, "brand_name", extractString(params, "brand_name"))
|
||||||
|
appendPromptLine(&settings, "region", extractString(params, "region"))
|
||||||
|
appendPromptLine(&settings, "target_audience", extractString(params, "target_audience"))
|
||||||
|
appendPromptLine(&settings, "content_goal", extractString(params, "content_goal"))
|
||||||
|
appendPromptLine(&settings, "tone", extractString(params, "tone"))
|
||||||
|
appendPromptLine(&settings, "length_goal", extractString(params, "length_goal"))
|
||||||
|
if len(keywords) > 0 {
|
||||||
|
appendPromptLine(&settings, "keywords", strings.Join(keywords, "、"))
|
||||||
|
}
|
||||||
|
appendPromptLine(&settings, "preserve_points", extractString(params, "preserve_points"))
|
||||||
|
appendPromptLine(&settings, "avoid_points", extractString(params, "avoid_points"))
|
||||||
|
appendPromptLine(&settings, "extra_requirements", extractString(params, "extra_requirements"))
|
||||||
|
|
||||||
|
knowledgeSection := ""
|
||||||
|
if knowledgePrompt != "" {
|
||||||
|
knowledgeSection = "## 知识库参考\n" + knowledgePrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompts.ArticleImitationPrompt(
|
||||||
|
locale,
|
||||||
|
sourceMeta.String(),
|
||||||
|
settings.String(),
|
||||||
|
knowledgeSection,
|
||||||
|
sourceContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildImitationKnowledgeQuery(params map[string]interface{}) string {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 8)
|
||||||
|
appendValue := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts = append(parts, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendValue(extractString(params, "source_title"))
|
||||||
|
appendValue(extractString(params, "industry"))
|
||||||
|
appendValue(extractString(params, "brand_name"))
|
||||||
|
appendValue(extractString(params, "region"))
|
||||||
|
appendValue(extractString(params, "target_audience"))
|
||||||
|
appendValue(extractString(params, "content_goal"))
|
||||||
|
appendValue(extractString(params, "tone"))
|
||||||
|
appendValue(strings.Join(extractStringList(params["keywords"], 16), "\n"))
|
||||||
|
appendValue(extractString(params, "extra_requirements"))
|
||||||
|
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPromptLine(b *strings.Builder, label, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString("- " + prompts.ArticleImitationSettingLabel(label) + ":" + value + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendRawPromptLine(b *strings.Builder, label, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString("- " + label + ":" + value + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGeneratedImitationTitle(markdown string, params map[string]interface{}) string {
|
||||||
|
for _, line := range strings.Split(markdown, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
title := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||||
|
if title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildImitationInitialTitle(extractString(params, "source_title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImitationLocale(value string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(value), "en-US") {
|
||||||
|
return "en-US"
|
||||||
|
}
|
||||||
|
return "zh-CN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImitationKeywords(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
keywords := make([]string, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(item)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
keywords = append(keywords, item)
|
||||||
|
if len(keywords) >= 16 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keywords
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(value string, maxRunes int) string {
|
||||||
|
if maxRunes <= 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
runes := []rune(value)
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return string(runes[:maxRunes])
|
||||||
|
}
|
||||||
@@ -94,6 +94,8 @@ type ArticleListItem struct {
|
|||||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
SourceLabel *string `json:"source_label"`
|
SourceLabel *string `json:"source_label"`
|
||||||
|
SourceURL *string `json:"source_url,omitempty"`
|
||||||
|
SourceTitle *string `json:"source_title,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +129,8 @@ type ArticleDetailResponse struct {
|
|||||||
MarkdownContent *string `json:"markdown_content"`
|
MarkdownContent *string `json:"markdown_content"`
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
SourceLabel *string `json:"source_label"`
|
SourceLabel *string `json:"source_label"`
|
||||||
|
SourceURL *string `json:"source_url,omitempty"`
|
||||||
|
SourceTitle *string `json:"source_title,omitempty"`
|
||||||
VersionNo *int `json:"version_no"`
|
VersionNo *int `json:"version_no"`
|
||||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||||
@@ -205,7 +209,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
|||||||
argIdx++
|
argIdx++
|
||||||
}
|
}
|
||||||
if params.Keyword != nil {
|
if params.Keyword != nil {
|
||||||
countQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
|
countQuery += ` AND (
|
||||||
|
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
||||||
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
||||||
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
||||||
|
)`
|
||||||
countArgs = append(countArgs, *params.Keyword)
|
countArgs = append(countArgs, *params.Keyword)
|
||||||
argIdx++
|
argIdx++
|
||||||
}
|
}
|
||||||
@@ -274,7 +282,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
|||||||
listIdx++
|
listIdx++
|
||||||
}
|
}
|
||||||
if params.Keyword != nil {
|
if params.Keyword != nil {
|
||||||
listQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'`
|
listQuery += ` AND (
|
||||||
|
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
||||||
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
||||||
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
||||||
|
)`
|
||||||
listArgs = append(listArgs, *params.Keyword)
|
listArgs = append(listArgs, *params.Keyword)
|
||||||
listIdx++
|
listIdx++
|
||||||
}
|
}
|
||||||
@@ -311,6 +323,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
|||||||
item.SourceType = dbSourceType
|
item.SourceType = dbSourceType
|
||||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||||
|
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
||||||
@@ -361,6 +374,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
|||||||
}
|
}
|
||||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||||
|
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
||||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||||
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
||||||
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
|
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
|
||||||
@@ -1080,6 +1094,24 @@ func resolveArticleGenerationMode(sourceType string, inputParamsJSON []byte) *st
|
|||||||
return &mode
|
return &mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveArticleImitationSourceInfo(sourceType string, wizardStateJSON, inputParamsJSON []byte) (*string, *string) {
|
||||||
|
if sourceType != articleImitationSourceType {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURL := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_url"))
|
||||||
|
if sourceURL == "" {
|
||||||
|
sourceURL = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_url"))
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceTitle := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_title"))
|
||||||
|
if sourceTitle == "" {
|
||||||
|
sourceTitle = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nilIfEmptyString(sourceURL), nilIfEmptyString(sourceTitle)
|
||||||
|
}
|
||||||
|
|
||||||
func nilIfEmptyString(value string) *string {
|
func nilIfEmptyString(value string) *string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
|
|||||||
AND template_id = $4
|
AND template_id = $4
|
||||||
AND source_type = 'template'
|
AND source_type = 'template'
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND generate_status IN ('draft', 'failed')
|
AND generate_status = 'draft'
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, stateJSON, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
`, stateJSON, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -333,7 +333,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
|||||||
AND template_id = $3
|
AND template_id = $3
|
||||||
AND source_type = 'template'
|
AND source_type = 'template'
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND generate_status IN ('draft', 'failed')
|
AND generate_status = 'draft'
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -630,6 +630,8 @@ func generationFailureStageLabel(stage string) string {
|
|||||||
return "AI 正文生成失败"
|
return "AI 正文生成失败"
|
||||||
case "knowledge_resolve":
|
case "knowledge_resolve":
|
||||||
return "知识库检索失败"
|
return "知识库检索失败"
|
||||||
|
case "source_fetch":
|
||||||
|
return "源文章抓取失败"
|
||||||
case "persist_begin_tx":
|
case "persist_begin_tx":
|
||||||
return "写入生成结果事务启动失败"
|
return "写入生成结果事务启动失败"
|
||||||
case "persist_create_version":
|
case "persist_create_version":
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ type runtimePromptsConfig struct {
|
|||||||
PromptRuleTargetPlatformSupplementFormat string `yaml:"prompt_rule_target_platform_supplement_format"`
|
PromptRuleTargetPlatformSupplementFormat string `yaml:"prompt_rule_target_platform_supplement_format"`
|
||||||
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
|
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
|
||||||
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
||||||
|
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
|
||||||
|
ArticleImitationLocaleInstructions map[string]string `yaml:"article_imitation_locale_instructions"`
|
||||||
|
ArticleImitationSettingLabels map[string]string `yaml:"article_imitation_setting_labels"`
|
||||||
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
|
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
|
||||||
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
|
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
|
||||||
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
|
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
|
||||||
|
|||||||
@@ -136,6 +136,55 @@ func PromptRuleOutputRequirementsSection() string {
|
|||||||
return trimPrompt(loadRuntimePrompts().PromptRuleOutputRequirementsSection)
|
return trimPrompt(loadRuntimePrompts().PromptRuleOutputRequirementsSection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ArticleImitationPrompt(locale, sourceMeta, settingsBlock, knowledgeSection, sourceContent string) string {
|
||||||
|
runtime := loadRuntimePrompts()
|
||||||
|
template := trimPrompt(runtime.ArticleImitationPromptTemplate)
|
||||||
|
if template == "" {
|
||||||
|
template = `你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 源文章
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 仿写设置
|
||||||
|
%s
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 源文章内容
|
||||||
|
%s
|
||||||
|
|
||||||
|
请开始输出仿写后的完整文章。`
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprintf(
|
||||||
|
template,
|
||||||
|
ArticleImitationLocaleInstruction(locale),
|
||||||
|
strings.TrimSpace(sourceMeta),
|
||||||
|
strings.TrimSpace(settingsBlock),
|
||||||
|
strings.TrimSpace(knowledgeSection),
|
||||||
|
strings.TrimSpace(sourceContent),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ArticleImitationLocaleInstruction(locale string) string {
|
||||||
|
instructions := loadRuntimePrompts().ArticleImitationLocaleInstructions
|
||||||
|
if value, ok := instructions[strings.TrimSpace(locale)]; ok && strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(locale), "en-US") {
|
||||||
|
return "Output language: English."
|
||||||
|
}
|
||||||
|
return "输出语言:中文简体。"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ArticleImitationSettingLabel(key string) string {
|
||||||
|
labels := loadRuntimePrompts().ArticleImitationSettingLabels
|
||||||
|
if value, ok := labels[strings.TrimSpace(key)]; ok && strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
func KnowledgePromptIntroLines() []string {
|
func KnowledgePromptIntroLines() []string {
|
||||||
lines := loadRuntimePrompts().KnowledgePromptIntroLines
|
lines := loadRuntimePrompts().KnowledgePromptIntroLines
|
||||||
return append([]string(nil), lines...)
|
return append([]string(nil), lines...)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type ArticleHandler struct {
|
|||||||
svc *app.ArticleService
|
svc *app.ArticleService
|
||||||
selectionOptimize *app.ArticleSelectionOptimizeService
|
selectionOptimize *app.ArticleSelectionOptimizeService
|
||||||
promptGenerateSvc *app.PromptRuleGenerationService
|
promptGenerateSvc *app.PromptRuleGenerationService
|
||||||
|
imitationSvc *app.ArticleImitationService
|
||||||
streams *stream.GenerationHub
|
streams *stream.GenerationHub
|
||||||
streamEnabled bool
|
streamEnabled bool
|
||||||
assets *AssetHandler
|
assets *AssetHandler
|
||||||
@@ -57,6 +58,15 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
|||||||
a.Config.Generation,
|
a.Config.Generation,
|
||||||
a.Config.LLM.MaxOutputTokens,
|
a.Config.LLM.MaxOutputTokens,
|
||||||
).WithCache(a.Cache),
|
).WithCache(a.Cache),
|
||||||
|
imitationSvc: app.NewArticleImitationService(
|
||||||
|
a.DB,
|
||||||
|
a.LLM,
|
||||||
|
a.RabbitMQ,
|
||||||
|
knowledgeSvc,
|
||||||
|
a.GenerationStreams,
|
||||||
|
a.Config.Generation,
|
||||||
|
a.Config.LLM.MaxOutputTokens,
|
||||||
|
).WithCache(a.Cache),
|
||||||
streams: a.GenerationStreams,
|
streams: a.GenerationStreams,
|
||||||
streamEnabled: a.Config.Generation.StreamEnabled,
|
streamEnabled: a.Config.Generation.StreamEnabled,
|
||||||
assets: NewAssetHandler(a),
|
assets: NewAssetHandler(a),
|
||||||
@@ -162,6 +172,22 @@ func (h *ArticleHandler) GenerateFromRule(c *gin.Context) {
|
|||||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ArticleHandler) GenerateImitation(c *gin.Context) {
|
||||||
|
var req app.GenerateImitationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.imitationSvc.Generate(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ArticleHandler) Create(c *gin.Context) {
|
func (h *ArticleHandler) Create(c *gin.Context) {
|
||||||
var req app.CreateArticleRequest
|
var req app.CreateArticleRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
articles.GET("", artHandler.List)
|
articles.GET("", artHandler.List)
|
||||||
articles.POST("", artHandler.Create)
|
articles.POST("", artHandler.Create)
|
||||||
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
||||||
|
articles.POST("/imitations/generate", artHandler.GenerateImitation)
|
||||||
articles.POST("/:id/images", artHandler.UploadImage)
|
articles.POST("/:id/images", artHandler.UploadImage)
|
||||||
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
||||||
articles.GET("/:id", artHandler.Detail)
|
articles.GET("/:id", artHandler.Detail)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -28,6 +29,7 @@ type ArticleGenerationWorker struct {
|
|||||||
rabbitMQ *rabbitmq.Client
|
rabbitMQ *rabbitmq.Client
|
||||||
templateService *tenantapp.TemplateService
|
templateService *tenantapp.TemplateService
|
||||||
promptRuleService *tenantapp.PromptRuleGenerationService
|
promptRuleService *tenantapp.PromptRuleGenerationService
|
||||||
|
imitationService *tenantapp.ArticleImitationService
|
||||||
kolWorker *KolGenerationWorker
|
kolWorker *KolGenerationWorker
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
retryInterval time.Duration
|
retryInterval time.Duration
|
||||||
@@ -41,6 +43,7 @@ func NewArticleGenerationWorker(
|
|||||||
rabbitMQClient *rabbitmq.Client,
|
rabbitMQClient *rabbitmq.Client,
|
||||||
templateService *tenantapp.TemplateService,
|
templateService *tenantapp.TemplateService,
|
||||||
promptRuleService *tenantapp.PromptRuleGenerationService,
|
promptRuleService *tenantapp.PromptRuleGenerationService,
|
||||||
|
imitationService *tenantapp.ArticleImitationService,
|
||||||
kolWorker *KolGenerationWorker,
|
kolWorker *KolGenerationWorker,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
cfg config.GenerationConfig,
|
cfg config.GenerationConfig,
|
||||||
@@ -60,6 +63,7 @@ func NewArticleGenerationWorker(
|
|||||||
rabbitMQ: rabbitMQClient,
|
rabbitMQ: rabbitMQClient,
|
||||||
templateService: templateService,
|
templateService: templateService,
|
||||||
promptRuleService: promptRuleService,
|
promptRuleService: promptRuleService,
|
||||||
|
imitationService: imitationService,
|
||||||
kolWorker: kolWorker,
|
kolWorker: kolWorker,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
retryInterval: defaultArticleGenerationWorkerRetryInterval,
|
retryInterval: defaultArticleGenerationWorkerRetryInterval,
|
||||||
@@ -145,11 +149,7 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if task.ID > 0 {
|
if task.ID > 0 {
|
||||||
if task.TaskType == kolGenerationTaskType && w.kolWorker != nil {
|
w.handleTaskFailure(ctx, task, "task_load", err)
|
||||||
w.kolWorker.HandleTaskFailure(ctx, task, "task_load", err)
|
|
||||||
} else {
|
|
||||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, err)
|
|
||||||
}
|
|
||||||
if ackErr := delivery.Ack(false); ackErr != nil && w.logger != nil {
|
if ackErr := delivery.Ack(false); ackErr != nil && w.logger != nil {
|
||||||
w.logger.Warn("article generation ack failed after task load failure",
|
w.logger.Warn("article generation ack failed after task load failure",
|
||||||
zap.Error(ackErr),
|
zap.Error(ackErr),
|
||||||
@@ -172,25 +172,28 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
task.TaskType = strings.TrimSpace(task.TaskType)
|
||||||
switch {
|
switch {
|
||||||
case task.ArticleID <= 0 || task.QuotaReservationID <= 0:
|
case task.ArticleID <= 0 || task.QuotaReservationID <= 0:
|
||||||
if task.TaskType == kolGenerationTaskType && w.kolWorker != nil {
|
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("generation task references are incomplete"))
|
||||||
w.kolWorker.HandleTaskFailure(ctx, task, "task_load", fmt.Errorf("generation task references are incomplete"))
|
|
||||||
} else {
|
|
||||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, fmt.Errorf("generation task references are incomplete"))
|
|
||||||
}
|
|
||||||
case task.TaskType == "template":
|
case task.TaskType == "template":
|
||||||
w.templateService.ProcessQueuedTask(ctx, task)
|
w.templateService.ProcessQueuedTask(ctx, task)
|
||||||
case task.TaskType == "custom_generation":
|
case task.TaskType == "custom_generation":
|
||||||
w.promptRuleService.ProcessQueuedTask(ctx, task)
|
w.promptRuleService.ProcessQueuedTask(ctx, task)
|
||||||
|
case task.TaskType == "imitation":
|
||||||
|
if w.imitationService != nil {
|
||||||
|
w.imitationService.ProcessQueuedTask(ctx, task)
|
||||||
|
} else {
|
||||||
|
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("imitation generation worker is not configured"))
|
||||||
|
}
|
||||||
case task.TaskType == kolGenerationTaskType:
|
case task.TaskType == kolGenerationTaskType:
|
||||||
if w.kolWorker != nil {
|
if w.kolWorker != nil {
|
||||||
w.kolWorker.Process(ctx, task)
|
w.kolWorker.Process(ctx, task)
|
||||||
} else {
|
} else {
|
||||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, fmt.Errorf("kol generation worker is not configured"))
|
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("kol generation worker is not configured"))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, fmt.Errorf("unsupported generation task type: %s", task.TaskType))
|
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("unsupported generation task type: %s", task.TaskType))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := delivery.Ack(false); err != nil && w.logger != nil {
|
if err := delivery.Ack(false); err != nil && w.logger != nil {
|
||||||
@@ -202,6 +205,18 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *ArticleGenerationWorker) handleTaskFailure(ctx context.Context, task tenantapp.ClaimedGenerationTask, stage string, taskErr error) {
|
||||||
|
task.TaskType = strings.TrimSpace(task.TaskType)
|
||||||
|
switch {
|
||||||
|
case task.TaskType == kolGenerationTaskType && w.kolWorker != nil:
|
||||||
|
w.kolWorker.HandleTaskFailure(ctx, task, stage, taskErr)
|
||||||
|
case task.TaskType == "imitation" && w.imitationService != nil:
|
||||||
|
w.imitationService.HandleTaskFailure(ctx, task, stage, taskErr)
|
||||||
|
default:
|
||||||
|
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, taskErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (w *ArticleGenerationWorker) loadTask(ctx context.Context, taskID int64) (tenantapp.ClaimedGenerationTask, bool, error) {
|
func (w *ArticleGenerationWorker) loadTask(ctx context.Context, taskID int64) (tenantapp.ClaimedGenerationTask, bool, error) {
|
||||||
var (
|
var (
|
||||||
task tenantapp.ClaimedGenerationTask
|
task tenantapp.ClaimedGenerationTask
|
||||||
|
|||||||
Reference in New Issue
Block a user