Files
geo/apps/admin-web/src/views/ImitationGenerateView.vue
T
root 9ed857e159
Frontend CI / Frontend (push) Successful in 3m19s
Backend CI / Backend (push) Successful in 14m30s
feat: paginate and search brand questions across selects
Make GET /api/tenant/brands/:id/questions return a paginated
QuestionListResponse (items/total/page/page_size) with optional `q`
full-text filter, validated page/page_size query params, and per-params
cache keys. Add a usePaginatedBrandQuestions composable backing the
imitation, template-wizard, and tracking question selects with
search-as-you-type and infinite scroll, plus ensure-loaded-by-id so a
deep-linked or pre-selected question is fetched even when off the first
page. Brands view now drives its questions table via server pagination.

Also redesign the Tracking hot-questions and cited-articles lists
(mention-rate badges/bars, metric groups, refreshed styling) and fall
back to citation-fact article_id/title when no high-confidence URL alias
matches, so cited articles surface even without an alias row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:01:23 +08:00

564 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, useQuery, 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'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company'
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const form = reactive({
source_url: '',
source_title: '',
locale: 'zh-CN',
region: '',
keywords: [] as string[],
preserve_points: '',
avoid_points: '',
extra_requirements: '',
enable_web_search: false,
knowledge_group_ids: [] as number[],
})
const selectedBrand = computed(() => companyStore.currentBrand)
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
const currentBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
const questionList = usePaginatedBrandQuestions(selectedBrandId)
const keywordOptions = computed(() =>
questionList.items.value.map((item) => ({
label: item.question_text,
value: item.question_text,
})),
)
const canSubmit = computed(
() =>
form.source_url.trim().length > 0 &&
currentBrandName.value.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,
brand_name: currentBrandName.value,
region: form.region.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
}
if (!currentBrandName.value) {
message.warning(t('imitation.create.brandNameRequired'))
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 class="required-asterisk">{{ t('imitation.create.brandName') }}</label>
<div class="current-brand-field">
<strong>{{ currentBrandName || t('shell.currentCompanyPlaceholder') }}</strong>
<span v-if="selectedBrand?.website">{{ selectedBrand.website }}</span>
</div>
</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 imitation-generate-page__field--wide">
<label>{{ t('imitation.create.keywords') }}</label>
<a-select
v-model:value="form.keywords"
mode="tags"
show-search
style="width: 100%"
:filter-option="false"
:loading="questionList.loading.value"
:options="keywordOptions"
:placeholder="t('imitation.create.keywordsPlaceholder')"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/>
</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: '';
}
.current-brand-field {
display: flex;
min-height: 32px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 5px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fafcff;
}
.current-brand-field strong {
overflow: hidden;
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-brand-field span {
overflow: hidden;
color: #667085;
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.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>