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 { 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;