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
@@ -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>