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:
2026-04-25 21:36:44 +08:00
parent 46d0a7aea0
commit 40d9a6cc63
29 changed files with 2678 additions and 120 deletions
@@ -1,5 +1,5 @@
<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 { message } 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),
);
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 {
activeTab.value = "content";
@@ -284,6 +286,27 @@ async function copyPublishLink(record: PublishRecord): Promise<void> {
<a-descriptions-item :label="t('article.meta.wordCount')">
{{ detail.word_count || "--" }}
</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>
</section>
@@ -423,6 +446,37 @@ async function copyPublishLink(record: PublishRecord): Promise<void> {
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 {
margin-top: 24px;
}
@@ -3,6 +3,7 @@ import {
AppstoreOutlined,
CalendarOutlined,
ClockCircleOutlined,
CopyOutlined,
EditOutlined,
TeamOutlined,
ThunderboltOutlined,
@@ -24,7 +25,7 @@ const sourceLabel = computed(() =>
const createdAtLabel = computed(() => formatDateTime(props.createdAt));
const sourceVariant = computed<
"template" | "custom" | "instant" | "schedule" | "free-create" | "kol" | "default"
"template" | "custom" | "instant" | "schedule" | "free-create" | "imitation" | "kol" | "default"
>(() => {
if (props.sourceType === "template") {
return "template";
@@ -32,6 +33,9 @@ const sourceVariant = computed<
if (props.sourceType === "free_create") {
return "free-create";
}
if (props.sourceType === "imitation") {
return "imitation";
}
if (props.sourceType === "kol") {
return "kol";
}
@@ -59,6 +63,8 @@ const badgeIcon = computed<Component>(() => {
return ThunderboltOutlined;
case "free-create":
return EditOutlined;
case "imitation":
return CopyOutlined;
case "kol":
return TeamOutlined;
default:
@@ -133,6 +139,12 @@ const badgeIcon = computed<Component>(() => {
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 {
border: 1px solid #d6e4ff;
background: linear-gradient(180deg, #f5f8ff 0%, #eaf1ff 100%);
@@ -508,13 +508,6 @@ function normalizePlatformUid(value?: string | null): string {
</div>
</section>
<a-alert
type="info"
show-icon
message="发布调度规则"
description="客户端在线时会立即开始消费;客户端离线时也允许提交,任务会先写入 SaaS 队列,客户端恢复在线后继续执行。"
/>
<section class="publish-modal__section">
<div class="publish-modal__section-header">
<h3><span class="required-star">*</span> 目标账号</h3>
+78
View File
@@ -71,6 +71,7 @@ const enUS = {
templates: "Templates",
custom: "Custom Generation",
freeCreate: "Free Create",
imitation: "Imitation Create",
media: "Media",
brandManagement: "Brand Management",
brands: "Brand Library",
@@ -150,6 +151,14 @@ const enUS = {
title: "Free Create",
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: {
title: "Media Management",
description: "Review desktop-managed media bindings, authorization state, and sync results in one place.",
@@ -385,6 +394,7 @@ const enUS = {
citationAnalysisTitle: "Citation Analysis",
contentCitations: "Content Citations",
contentCitationsTitle: "Content Citations",
imitationAction: "Imitation create",
taskDebugTitle: "Async Task Debug",
taskDebugHint: "For troubleshooting the callback -> queue -> worker flow only. The four main content sections above stay unchanged.",
contentCitationBadge: "Content Citation",
@@ -551,9 +561,76 @@ const enUS = {
instant_task: "Instant task",
schedule_task: "Scheduled task",
free_create: "Free create",
imitation: "Imitation create",
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: {
actions: {
create: "New Article",
@@ -856,6 +933,7 @@ const enUS = {
templateType: "Template",
currentVersion: "Current version",
source: "Source label",
sourceArticle: "Source article",
wordCount: "Word count",
},
versionUntitled: "Untitled version",
+78
View File
@@ -71,6 +71,7 @@ const zhCN = {
templates: "模版创作",
custom: "自定义生成",
freeCreate: "自由创作",
imitation: "仿写创作",
media: "媒体管理",
brandManagement: "品牌管理",
brands: "品牌词库",
@@ -150,6 +151,14 @@ const zhCN = {
title: "自由创作",
description: "自由创建并编辑文章,无需模版或AI生成。",
},
imitation: {
title: "仿写创作",
description: "管理基于引用来源或外部文章发起的仿写生成记录。",
},
imitationCreate: {
title: "仿写创作",
description: "从文章链接提取内容后,一步生成适配当前场景的新文章。",
},
media: {
title: "媒体管理",
description: "统一查看桌面端媒体账号绑定、授权状态与同步结果。",
@@ -385,6 +394,7 @@ const zhCN = {
citationAnalysisTitle: "引用分析",
contentCitations: "Content Citations",
contentCitationsTitle: "内容引用",
imitationAction: "仿写创作",
taskDebugTitle: "异步任务调试",
taskDebugHint: "仅用于排查 callback -> queue -> worker 的异步流转,不影响上方四块主内容。",
contentCitationBadge: "内容引用",
@@ -551,9 +561,76 @@ const zhCN = {
instant_task: "即时任务",
schedule_task: "定时任务",
free_create: "自由创作",
imitation: "仿写创作",
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: {
actions: {
create: "新建文章",
@@ -863,6 +940,7 @@ const zhCN = {
templateType: "模版类型",
currentVersion: "当前版本",
source: "生成来源",
sourceArticle: "源文链接",
wordCount: "文章字数",
},
versionUntitled: "未命名版本",
+126 -7
View File
@@ -11,7 +11,7 @@ import {
TranslationOutlined,
} from "@ant-design/icons-vue";
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import { computed, type Component } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
@@ -39,6 +39,23 @@ const selectedKeys = computed(() => {
});
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(() => [
{ label: t("locale.zh-CN"), value: "zh-CN" },
{ 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";
});
const navSections = computed(() => {
const navSections = computed<NavSection[]>(() => {
const base = [
{
key: "workspace",
@@ -75,7 +92,7 @@ const navSections = computed(() => {
{ key: "/articles/templates", label: t("nav.templates") },
{ key: "/articles/custom", label: t("nav.custom") },
{ 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",
title: t("nav.contentManagement"),
items: [
{ key: "/media", label: t("nav.media"), icon: GlobalOutlined },
{ key: "/knowledge", label: t("nav.knowledge"), icon: BookOutlined },
{ key: "/images", label: t("nav.images"), icon: PictureOutlined },
],
@@ -124,6 +142,43 @@ const navSections = computed(() => {
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 {
void router.push(path);
}
@@ -181,7 +236,29 @@ async function handleLogout(): Promise<void> {
<a-layout class="admin-main-layout">
<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-locale-switch">
@@ -296,13 +373,55 @@ async function handleLogout(): Promise<void> {
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.admin-header-title {
margin: 0;
font-size: 20px;
.admin-header-breadcrumb {
min-width: 0;
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;
}
.admin-header-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 20px;
+8
View File
@@ -7,6 +7,8 @@ import type {
ArticleListResponse,
CreateArticleRequest,
ArticleVersion,
GenerateImitationRequest,
GenerateImitationResponse,
AuthTokens,
Brand,
BrandLibrarySummary,
@@ -559,6 +561,12 @@ export const articlesApi = {
create(payload: CreateArticleRequest = {}) {
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) {
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`);
},
+11 -1
View File
@@ -19,7 +19,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
};
}
if (generateStatus === "draft" || generateStatus === "failed") {
if (generateStatus === "draft") {
return {
showPublish: false,
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 {
showPublish: false,
showEdit: false,
+3
View File
@@ -77,6 +77,9 @@ export function getSourceTypeLabel(sourceType?: string | null, generationMode?:
if (sourceType === "free_create") {
return i18n.global.t("status.sourceType.free_create");
}
if (sourceType === "imitation") {
return i18n.global.t("status.sourceType.imitation");
}
if (sourceType === "kol") {
return i18n.global.t("status.sourceType.kol");
}
+1 -1
View File
@@ -29,7 +29,7 @@ const errorMessageMap: Record<string, string> = {
article_generating: "文章仍在生成中",
article_not_editable: "只有已完成文章才可编辑",
article_lookup_failed: "文章状态读取失败",
draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑",
draft_not_editable: "只有草稿文章才可继续在向导中编辑",
article_title_required: "请输入文章标题",
invalid_image: "请选择图片文件",
image_upload_too_large: "图片不能超过 10MB",
+20
View File
@@ -94,6 +94,26 @@ const router = createRouter({
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",
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>
+632
View File
@@ -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>
+91 -74
View File
@@ -52,40 +52,44 @@ function handleCardClick(id: number) {
<template>
<div class="kol-marketplace">
<div class="page-header">
<div class="header-content">
<h1>{{ t("kol.marketplace.title") }}</h1>
<p class="description">{{ t("route.templates.description") }}</p>
<section class="kol-marketplace__top-card">
<div class="kol-marketplace__header">
<div class="kol-marketplace__header-title">
<h2>{{ t("kol.marketplace.title") }}</h2>
<p>{{ t("route.templates.description") }}</p>
</div>
</div>
</div>
<div class="filters-card">
<div class="filter-item">
<label>{{ t("kol.marketplace.filter.industry") }}</label>
<a-select
v-model:value="filters.industry"
style="width: 160px"
:options="industryOptions"
:placeholder="t('common.selectPlease')"
allow-clear
/>
<a-divider style="margin: 0; border-color: #f0f0f0;" />
<div class="kol-marketplace__filters">
<div class="kol-marketplace__filters-inline">
<div class="kol-marketplace__filter-item">
<label>{{ t("kol.marketplace.filter.industry") }}:</label>
<a-select
v-model:value="filters.industry"
: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 class="filter-item">
<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>
</section>
<div class="marketplace-grid" v-if="marketplaceQuery.data.value?.length">
<a-row :gutter="[24, 24]">
@@ -116,51 +120,70 @@ function handleCardClick(id: number) {
.kol-marketplace {
display: flex;
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;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
}
.header-content h1 {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.description {
color: #8c8c8c;
font-size: 14px;
.kol-marketplace__header-title h2 {
margin: 0;
}
.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;
font-size: 20px;
font-weight: 600;
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;
}
.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 {
margin-top: 8px;
}
@@ -173,14 +196,8 @@ function handleCardClick(id: number) {
}
@media (max-width: 768px) {
.filters-card {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.filter-item :deep(.ant-select),
.filter-item :deep(.ant-input-search) {
.kol-marketplace__filter-item :deep(.ant-select),
.kol-marketplace__filter-item :deep(.ant-input-search) {
width: 100% !important;
}
}
@@ -501,7 +501,7 @@ watch(
}
resetWizardState(detail);
if (draftArticle && (draftArticle.generate_status === "draft" || draftArticle.generate_status === "failed")) {
if (draftArticle && draftArticle.generate_status === "draft") {
applyDraftArticleState(draftArticle);
}
},
+1 -1
View File
@@ -338,7 +338,7 @@ async function openEditor(article: ArticleListItem): Promise<void> {
try {
const detail = await articlesApi.detail(article.id);
if (detail.generate_status !== "completed" && detail.source_type === "template" && detail.template_id) {
if (detail.generate_status === "draft" && detail.source_type === "template" && detail.template_id) {
await router.push({
name: "article-wizard",
query: {
@@ -1,5 +1,5 @@
<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 dayjs from "dayjs";
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 {
if (value === null || value === undefined) {
return "--";
@@ -1025,10 +1048,11 @@ const citationAnalysisColumns = 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.citations"), key: "citationCount", dataIndex: "citationCount", align: "center" as const, width: "20%" },
{ title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citationRate", 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: "16%" },
{ title: t("common.actions"), key: "actions", align: "center" as const, width: "10%" },
]);
</script>
@@ -1138,15 +1162,16 @@ const contentCitationColumns = computed(() => [
<div ref="citationSourcesScrollRef" class="tracking-question-card__scroll-area custom-scrollbar">
<div v-if="activePlatform?.citations?.length" class="tracking-question-source-list">
<a
<div
v-for="(citation, index) in activePlatform.citations"
:key="citation.cited_url"
:href="citation.cited_url"
target="_blank"
rel="noreferrer"
role="link"
tabindex="0"
class="tracking-question-source-card"
:class="{ 'is-linked': highlightedCitationIndex === 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__indexes">
@@ -1168,6 +1193,15 @@ const contentCitationColumns = computed(() => [
<strong>{{ citation.site_name }}</strong>
<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>
<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
v-if="citation.article_id && supportsContentCitationDisplay(activePlatform?.ai_platform_id)"
color="blue"
@@ -1179,7 +1213,7 @@ const contentCitationColumns = computed(() => [
<div class="tracking-question-source-card__link">
{{ citation.cited_url }}
</div>
</a>
</div>
</div>
<a-empty v-else :description="t('tracking.noCitations')" />
</div>
@@ -1279,6 +1313,18 @@ const contentCitationColumns = computed(() => [
<template v-else-if="column.key === 'citationRate'">
<strong class="metric-highlight">{{ formatPercent(record.citationRate) }}</strong>
</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>
</a-table>
</div>
@@ -1546,6 +1592,7 @@ const contentCitationColumns = computed(() => [
.tracking-question-source-card,
.tracking-question-content-card {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
@@ -1553,6 +1600,7 @@ const contentCitationColumns = computed(() => [
border: 1px solid #edf2f7;
border-radius: 10px;
background: #f8fbff;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
@@ -1563,6 +1611,10 @@ const contentCitationColumns = computed(() => [
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
}
.tracking-question-source-card {
padding-right: 58px;
}
.tracking-question-source-card.is-linked {
border-color: #93c5fd;
background: #eef6ff;
@@ -1649,6 +1701,62 @@ const contentCitationColumns = computed(() => [
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 {
color: #94a3b8;
font-size: 12px;
+1 -1
View File
@@ -190,7 +190,7 @@ async function openEditor(article: RecentArticle): Promise<void> {
try {
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({
name: "article-wizard",
query: {