9ed857e159
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>
564 lines
15 KiB
Vue
564 lines
15 KiB
Vue
<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>
|