Files
geo/apps/admin-web/src/views/ImitationGenerateView.vue
T
root 40d9a6cc63 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>
2026-04-25 21:36:44 +08:00

532 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>