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>
This commit is contained in:
@@ -124,6 +124,7 @@ import type {
|
||||
QuestionCombinationFillResult,
|
||||
QuestionCombinationRequest,
|
||||
QuestionDistillRequest,
|
||||
QuestionListResponse,
|
||||
QuestionRequest,
|
||||
QuotaSummary,
|
||||
RecentArticle,
|
||||
@@ -1139,9 +1140,12 @@ export const brandsApi = {
|
||||
removeKeyword(brandId: number, keywordId: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`)
|
||||
},
|
||||
listQuestions(brandId: number, keywordId?: number | null) {
|
||||
return apiClient.get<Question[]>(`/api/tenant/brands/${brandId}/questions`, {
|
||||
params: keywordId ? { keyword_id: keywordId } : undefined,
|
||||
listQuestions(
|
||||
brandId: number,
|
||||
params?: { keyword_id?: number | null; page?: number; page_size?: number; q?: string },
|
||||
) {
|
||||
return apiClient.get<QuestionListResponse>(`/api/tenant/brands/${brandId}/questions`, {
|
||||
params,
|
||||
})
|
||||
},
|
||||
previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Question } from '@geo/shared-types'
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { brandsApi } from './api'
|
||||
|
||||
const defaultQuestionPageSize = 20
|
||||
const questionListAllOptionValue = 'all'
|
||||
|
||||
export type QuestionSelectOption = {
|
||||
label: string
|
||||
value: number | string
|
||||
}
|
||||
|
||||
export function usePaginatedBrandQuestions(
|
||||
brandId: Ref<number | null | undefined>,
|
||||
options: {
|
||||
pageSize?: number
|
||||
includeAllOption?: boolean
|
||||
allOptionLabel?: () => string
|
||||
} = {},
|
||||
) {
|
||||
const pageSize = options.pageSize ?? defaultQuestionPageSize
|
||||
const searchText = ref('')
|
||||
const items = ref<Question[]>([])
|
||||
const selectedItems = ref<Question[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const requestSeq = ref(0)
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
const questionOptions = computed<QuestionSelectOption[]>(() => {
|
||||
const map = new Map<number | string, QuestionSelectOption>()
|
||||
if (options.includeAllOption) {
|
||||
map.set(questionListAllOptionValue, {
|
||||
label: options.allOptionLabel?.() ?? 'All',
|
||||
value: questionListAllOptionValue,
|
||||
})
|
||||
}
|
||||
for (const item of [...selectedItems.value, ...items.value]) {
|
||||
map.set(item.id, {
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
function loadPage(nextPage: number, mode: 'replace' | 'append'): Promise<void> {
|
||||
const currentBrandId = brandId.value
|
||||
if (!currentBrandId) {
|
||||
items.value = []
|
||||
total.value = 0
|
||||
page.value = 1
|
||||
initialized.value = true
|
||||
pendingLoad = null
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const seq = ++requestSeq.value
|
||||
loading.value = true
|
||||
const loadPromise = (async () => {
|
||||
const result = await brandsApi.listQuestions(currentBrandId, {
|
||||
page: nextPage,
|
||||
page_size: pageSize,
|
||||
q: searchText.value.trim() || undefined,
|
||||
})
|
||||
if (seq !== requestSeq.value) {
|
||||
return
|
||||
}
|
||||
page.value = result.page
|
||||
total.value = result.total
|
||||
items.value = mode === 'append' ? mergeQuestions(items.value, result.items) : result.items
|
||||
initialized.value = true
|
||||
})()
|
||||
.finally(() => {
|
||||
if (seq === requestSeq.value) {
|
||||
loading.value = false
|
||||
}
|
||||
if (pendingLoad === loadPromise) {
|
||||
pendingLoad = null
|
||||
}
|
||||
})
|
||||
pendingLoad = loadPromise
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
void loadPage(1, 'replace')
|
||||
}
|
||||
|
||||
function search(value: string): void {
|
||||
searchText.value = String(value ?? '').trim()
|
||||
reset()
|
||||
}
|
||||
|
||||
function loadNextPage(): Promise<void> {
|
||||
if (loading.value || !hasMore.value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return loadPage(page.value + 1, 'append')
|
||||
}
|
||||
|
||||
function handlePopupScroll(event: Event): void {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQuestionLoadedById(id: number | null | undefined): Promise<Question | null> {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
let existing = findQuestionById(id)
|
||||
if (existing) {
|
||||
rememberSelectedByIds([id])
|
||||
return existing
|
||||
}
|
||||
|
||||
const currentBrandId = brandId.value
|
||||
if (!currentBrandId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (pendingLoad) {
|
||||
await pendingLoad
|
||||
existing = findQuestionById(id)
|
||||
if (existing) {
|
||||
rememberSelectedByIds([id])
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
await loadPage(1, 'replace')
|
||||
}
|
||||
|
||||
existing = findQuestionById(id)
|
||||
while (!existing && hasMore.value && brandId.value === currentBrandId) {
|
||||
await loadNextPage()
|
||||
existing = findQuestionById(id)
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
rememberSelectedByIds([id])
|
||||
}
|
||||
return existing ?? null
|
||||
}
|
||||
|
||||
function rememberSelectedByIds(ids: Array<number | null | undefined>): void {
|
||||
const wanted = new Set(ids.filter((id): id is number => Number.isFinite(id ?? NaN)))
|
||||
if (!wanted.size) {
|
||||
return
|
||||
}
|
||||
selectedItems.value = mergeQuestions(
|
||||
selectedItems.value,
|
||||
items.value.filter((item) => wanted.has(item.id)),
|
||||
)
|
||||
}
|
||||
|
||||
function findQuestionById(id: number | null | undefined): Question | null {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
items.value.find((item) => item.id === id) ??
|
||||
selectedItems.value.find((item) => item.id === id) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => brandId.value,
|
||||
() => {
|
||||
searchText.value = ''
|
||||
selectedItems.value = []
|
||||
reset()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
hasMore,
|
||||
initialized,
|
||||
items,
|
||||
loading,
|
||||
options: questionOptions,
|
||||
search,
|
||||
searchText,
|
||||
handlePopupScroll,
|
||||
ensureQuestionLoadedById,
|
||||
findQuestionById,
|
||||
rememberSelectedByIds,
|
||||
refetch: reset,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeQuestions(left: Question[], right: Question[]): Question[] {
|
||||
const map = new Map<number, Question>()
|
||||
for (const item of left) {
|
||||
map.set(item.id, item)
|
||||
}
|
||||
for (const item of right) {
|
||||
map.set(item.id, item)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type { BrandLibrarySummary, QuestionCandidate, QuestionSource } from '@geo/shared-types'
|
||||
import type {
|
||||
BrandLibrarySummary,
|
||||
Question,
|
||||
QuestionCandidate,
|
||||
QuestionSource,
|
||||
} from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
@@ -102,7 +107,7 @@ const brandLibrarySummaryQuery = useQuery({
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', brandId.value, 'questions', 'all']),
|
||||
enabled: computed(() => brandId.value > 0),
|
||||
queryFn: () => brandsApi.listQuestions(brandId.value),
|
||||
queryFn: () => listAllBrandQuestions(brandId.value),
|
||||
})
|
||||
|
||||
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
|
||||
@@ -444,6 +449,29 @@ function mergeQuestionCandidateResults(
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllBrandQuestions(currentBrandId: number): Promise<Question[]> {
|
||||
const pageSize = 100
|
||||
const items: Question[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const result = await brandsApi.listQuestions(currentBrandId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
items.push(...result.items)
|
||||
total = result.total
|
||||
|
||||
if (items.length >= total || result.items.length === 0) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
} while (true)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix'
|
||||
|
||||
function handleInput(column: ColumnKey, index: number) {
|
||||
|
||||
@@ -63,6 +63,9 @@ const competitorForm = reactive({
|
||||
product_lines: '',
|
||||
})
|
||||
|
||||
const questionPage = ref(1)
|
||||
const questionPageSize = ref(10)
|
||||
|
||||
const brandListQuery = useQuery({
|
||||
queryKey: ['brands', 'list'],
|
||||
queryFn: () => brandsApi.list(),
|
||||
@@ -74,9 +77,19 @@ const brandLibrarySummaryQuery = useQuery({
|
||||
})
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'all']),
|
||||
queryKey: computed(() => [
|
||||
'brands',
|
||||
selectedBrandId.value,
|
||||
'questions',
|
||||
questionPage.value,
|
||||
questionPageSize.value,
|
||||
]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
queryFn: () =>
|
||||
brandsApi.listQuestions(selectedBrandId.value as number, {
|
||||
page: questionPage.value,
|
||||
page_size: questionPageSize.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const competitorsQuery = useQuery({
|
||||
@@ -93,7 +106,8 @@ const selectedBrand = computed(
|
||||
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
||||
)
|
||||
|
||||
const currentQuestions = computed(() => questionsQuery.data.value ?? [])
|
||||
const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
|
||||
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
|
||||
|
||||
const maxQuestions = computed(
|
||||
() =>
|
||||
@@ -102,7 +116,7 @@ const maxQuestions = computed(
|
||||
0,
|
||||
)
|
||||
const usedQuestions = computed(
|
||||
() => brandLibrarySummary.value?.used_questions ?? currentQuestions.value.length,
|
||||
() => brandLibrarySummary.value?.used_questions ?? questionTotal.value,
|
||||
)
|
||||
|
||||
const brandLimitReached = computed(() => {
|
||||
@@ -308,6 +322,13 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
|
||||
const maxPage = Math.max(1, Math.ceil(total / pageSize))
|
||||
if (questionPage.value > maxPage) {
|
||||
questionPage.value = maxPage
|
||||
}
|
||||
})
|
||||
|
||||
function parseProductLines(value: string): string[] | undefined {
|
||||
const lines = value
|
||||
.split(',')
|
||||
@@ -344,6 +365,7 @@ function selectBrand(brandId: number): void {
|
||||
return
|
||||
}
|
||||
selectedBrandId.value = brandId
|
||||
questionPage.value = 1
|
||||
void queryClient.invalidateQueries({ queryKey: ['brands'] })
|
||||
}
|
||||
|
||||
@@ -429,6 +451,11 @@ async function submitSingleQuestion(): Promise<void> {
|
||||
await questionMutations.createSingle.mutateAsync()
|
||||
}
|
||||
|
||||
function handleQuestionTableChange(nextPage: number, nextPageSize: number): void {
|
||||
questionPage.value = nextPage
|
||||
questionPageSize.value = nextPageSize
|
||||
}
|
||||
|
||||
async function submitCompetitor(): Promise<void> {
|
||||
if (!competitorForm.name.trim()) {
|
||||
return
|
||||
@@ -551,7 +578,13 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
:columns="questionColumns"
|
||||
:data-source="currentQuestions"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: false }"
|
||||
:pagination="{
|
||||
current: questionPage,
|
||||
pageSize: questionPageSize,
|
||||
total: questionTotal,
|
||||
showSizeChanger: false,
|
||||
onChange: handleQuestionTableChange,
|
||||
}"
|
||||
row-key="id"
|
||||
>
|
||||
<template #emptyText>{{ t('brands.empty.questions') }}</template>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
||||
import { articlesApi, brandsApi } from '@/lib/api'
|
||||
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()
|
||||
@@ -34,14 +35,10 @@ const selectedBrand = computed(() => companyStore.currentBrand)
|
||||
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
|
||||
const currentBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'imitation']),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
})
|
||||
const questionList = usePaginatedBrandQuestions(selectedBrandId)
|
||||
|
||||
const keywordOptions = computed(() =>
|
||||
(questionsQuery.data.value ?? []).map((item) => ({
|
||||
questionList.items.value.map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.question_text,
|
||||
})),
|
||||
@@ -239,9 +236,14 @@ function submit(): void {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
||||
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
|
||||
import { getTemplateMeta } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
interface DraftCompetitor {
|
||||
@@ -159,6 +160,7 @@ let outlineNodeSeed = 0
|
||||
let allowWizardLeave = false
|
||||
let pendingLeavePromise: Promise<boolean> | null = null
|
||||
let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null
|
||||
let selectedQuestionLoadSeq = 0
|
||||
const CUSTOM_OUTLINE_MAX_LENGTH = 15
|
||||
|
||||
const currentStep = ref(0)
|
||||
@@ -219,11 +221,7 @@ const draftArticleQuery = useQuery({
|
||||
queryFn: () => articlesApi.detail(draftArticleId.value as number),
|
||||
})
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
|
||||
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
})
|
||||
const questionList = usePaginatedBrandQuestions(selectedBrandId)
|
||||
|
||||
const competitorsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
|
||||
@@ -283,18 +281,10 @@ const templateMeta = computed(() => {
|
||||
})
|
||||
|
||||
function findQuestionById(id: number | null): Question | null {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return questionsQuery.data.value?.find((item) => item.id === id) ?? null
|
||||
return questionList.findQuestionById(id)
|
||||
}
|
||||
|
||||
const questionOptions = computed(() =>
|
||||
(questionsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})),
|
||||
)
|
||||
const questionOptions = computed(() => questionList.options.value)
|
||||
const supplementalQuestionOptions = computed(() =>
|
||||
questionOptions.value.filter((item) => item.value !== primaryQuestionId.value),
|
||||
)
|
||||
@@ -635,15 +625,46 @@ watch(primaryQuestionId, (id) => {
|
||||
supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id)
|
||||
})
|
||||
|
||||
watch(
|
||||
[primaryQuestionId, supplementalQuestionIds, () => questionList.items.value],
|
||||
([primaryId, supplementalIds]) => {
|
||||
questionList.rememberSelectedByIds([primaryId, ...supplementalIds])
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[selectedBrandId, primaryQuestionId, supplementalQuestionIds],
|
||||
async ([brandId, primaryId, supplementalIds]) => {
|
||||
if (!brandId) {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = [primaryId, ...supplementalIds].filter(
|
||||
(id): id is number => typeof id === 'number' && Number.isFinite(id) && id > 0,
|
||||
)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++selectedQuestionLoadSeq
|
||||
for (const id of ids) {
|
||||
await questionList.ensureQuestionLoadedById(id)
|
||||
if (seq !== selectedQuestionLoadSeq || brandId !== selectedBrandId.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(supplementalQuestionIds, (ids) => {
|
||||
const optionValues = supplementalQuestionOptions.value.map((item) => item.value)
|
||||
const allowedIds = new Set(optionValues)
|
||||
const shouldCheckOptions = optionValues.length > 0
|
||||
const normalized = ids.filter(
|
||||
(id, index) =>
|
||||
id !== primaryQuestionId.value &&
|
||||
ids.indexOf(id) === index &&
|
||||
(!shouldCheckOptions || allowedIds.has(id)),
|
||||
typeof id === 'number' &&
|
||||
Number.isFinite(id) &&
|
||||
id > 0,
|
||||
)
|
||||
const limited = normalized.slice(0, 3)
|
||||
if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) {
|
||||
@@ -676,7 +697,7 @@ watch(
|
||||
|
||||
primaryQuestionId.value = null
|
||||
supplementalQuestionIds.value = []
|
||||
await questionsQuery.refetch()
|
||||
await questionList.refetch()
|
||||
|
||||
if (!showCompetitorsCard.value) {
|
||||
competitorDrafts.value = []
|
||||
@@ -2472,7 +2493,8 @@ function onStructureDragEnd(): void {
|
||||
allow-clear
|
||||
show-search
|
||||
:disabled="!selectedBrandId"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:not-found-content="
|
||||
selectedBrandId
|
||||
? t('templates.wizard.hints.questionEmpty')
|
||||
@@ -2480,8 +2502,9 @@ function onStructureDragEnd(): void {
|
||||
"
|
||||
:options="questionOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.primaryQuestion')"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2495,13 +2518,15 @@ function onStructureDragEnd(): void {
|
||||
mode="multiple"
|
||||
show-search
|
||||
:disabled="!selectedBrandId || !primaryQuestionId"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:max-tag-count="3"
|
||||
:not-found-content="t('templates.wizard.hints.questionEmpty')"
|
||||
:options="supplementalQuestionOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.supplementalQuestions')"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
<p class="field-helper">
|
||||
{{ t('templates.wizard.hints.supplementalQuestions') }}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type {
|
||||
DesktopAccountInfo,
|
||||
MonitoringCitedArticle,
|
||||
@@ -17,7 +25,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.vue'
|
||||
import PageHero from '@/components/PageHero.vue'
|
||||
import { brandsApi, monitoringApi, tenantAccountsApi } from '@/lib/api'
|
||||
import { monitoringApi, tenantAccountsApi } from '@/lib/api'
|
||||
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
|
||||
import { formatDateTime } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
@@ -26,6 +34,7 @@ import {
|
||||
normalizeMonitoringPlatformFilter,
|
||||
normalizeMonitoringPlatformId,
|
||||
} from '@/lib/monitoring-platforms'
|
||||
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -113,6 +122,8 @@ const hotQuestionPage = ref(1)
|
||||
const citedArticlePage = ref(1)
|
||||
const articleDrawerOpen = ref(false)
|
||||
const selectedArticleId = ref<number | null>(null)
|
||||
const missingRequestedQuestionId = ref<number | null>(null)
|
||||
let questionSelectionSeq = 0
|
||||
|
||||
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
|
||||
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
|
||||
@@ -128,43 +139,62 @@ const selectedQuestionSetOptionValue = computed<string | number>({
|
||||
const selectedBrandId = computed(() => companyStore.currentBrandId)
|
||||
const selectedBrand = computed(() => companyStore.currentBrand)
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
const questionList = usePaginatedBrandQuestions(selectedBrandId, {
|
||||
includeAllOption: true,
|
||||
allOptionLabel: () => t('tracking.allQuestionSets'),
|
||||
})
|
||||
|
||||
const questionSetOptions = computed(() => [
|
||||
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
|
||||
...(questionsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})),
|
||||
])
|
||||
const questionSetOptions = computed(() => questionList.options.value)
|
||||
|
||||
watch(
|
||||
() => questionsQuery.data.value,
|
||||
(questions) => {
|
||||
[selectedBrandId, () => route.query.question_id, selectedQuestionId],
|
||||
async ([brandId, requestedQuestionValue, questionId]) => {
|
||||
const requestedQuestionId = parseNumericQuery(requestedQuestionValue)
|
||||
const targetQuestionId = requestedQuestionId ?? questionId
|
||||
if (!brandId || !targetQuestionId) {
|
||||
missingRequestedQuestionId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedQuestionId && missingRequestedQuestionId.value === requestedQuestionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++questionSelectionSeq
|
||||
if (requestedQuestionId) {
|
||||
missingRequestedQuestionId.value = null
|
||||
}
|
||||
|
||||
const question = await questionList.ensureQuestionLoadedById(targetQuestionId)
|
||||
if (seq !== questionSelectionSeq || brandId !== selectedBrandId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (question) {
|
||||
selectedQuestionId.value = targetQuestionId
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedQuestionId === targetQuestionId) {
|
||||
missingRequestedQuestionId.value = targetQuestionId
|
||||
}
|
||||
if (selectedQuestionId.value === targetQuestionId) {
|
||||
selectedQuestionId.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.question_id,
|
||||
(value) => {
|
||||
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||
if (!questions?.length) {
|
||||
if (!requestedQuestionId) {
|
||||
selectedQuestionId.value = null
|
||||
}
|
||||
if (missingRequestedQuestionId.value && missingRequestedQuestionId.value !== requestedQuestionId) {
|
||||
missingRequestedQuestionId.value = null
|
||||
}
|
||||
if (value || selectedBrandId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedQuestionId && questions.some((item) => item.id === requestedQuestionId)) {
|
||||
selectedQuestionId.value = requestedQuestionId
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
selectedQuestionId.value &&
|
||||
questions.some((item) => item.id === selectedQuestionId.value)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedQuestionId.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -198,11 +228,15 @@ watch(
|
||||
selectedQuestionId,
|
||||
selectedPlatformId,
|
||||
selectedBusinessDate,
|
||||
() => questionsQuery.data.value,
|
||||
missingRequestedQuestionId,
|
||||
],
|
||||
([brandId, questionId, platformId, businessDate, questions]) => {
|
||||
([brandId, questionId, platformId, businessDate, missingQuestionId]) => {
|
||||
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||
if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
|
||||
if (
|
||||
requestedQuestionId &&
|
||||
questionId !== requestedQuestionId &&
|
||||
missingQuestionId !== requestedQuestionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -688,16 +722,6 @@ function openCitedArticle(item: MonitoringCitedArticle): void {
|
||||
articleDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function filterQuestionOption(input: string, option?: { label?: string }): boolean {
|
||||
const keyword = input.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
return String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
@@ -705,6 +729,19 @@ function formatPercent(value: number | null | undefined): string {
|
||||
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`
|
||||
}
|
||||
|
||||
function getMentionRateClass(rate: number | null | undefined): string {
|
||||
if (rate === null || rate === undefined || rate === 0) {
|
||||
return 'rate-zero'
|
||||
}
|
||||
if (rate <= 0.3) {
|
||||
return 'rate-low'
|
||||
}
|
||||
if (rate <= 0.7) {
|
||||
return 'rate-mid'
|
||||
}
|
||||
return 'rate-high'
|
||||
}
|
||||
|
||||
function normalizeCitationWindowDays(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
return numeric === 30 ? 30 : 7
|
||||
@@ -870,11 +907,15 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
v-model:value="selectedQuestionSetOptionValue"
|
||||
show-search
|
||||
class="tracking-select"
|
||||
option-filter-prop="label"
|
||||
:filter-option="filterQuestionOption"
|
||||
:not-found-content="t('tracking.noQuestions')"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:not-found-content="
|
||||
questionList.loading.value ? t('common.loading') : t('tracking.noQuestions')
|
||||
"
|
||||
:placeholder="t('tracking.keywordPlaceholder')"
|
||||
:options="questionSetOptions"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
</div>
|
||||
<div class="tracking-action-item">
|
||||
@@ -1239,12 +1280,16 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
<h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3>
|
||||
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span>
|
||||
</div>
|
||||
<a-input-search
|
||||
<a-input
|
||||
v-model:value="hotQuestionSearch"
|
||||
allow-clear
|
||||
class="tracking-list-search"
|
||||
:placeholder="t('tracking.questionSearchPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="search-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<div v-if="hotQuestions.length" class="question-list">
|
||||
@@ -1255,15 +1300,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
class="question-list__item"
|
||||
@click="openQuestion(question)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ question.question_text }}</strong>
|
||||
<p>
|
||||
{{ t('tracking.metrics.mentionRate') }} {{ formatPercent(question.mention_rate) }}
|
||||
</p>
|
||||
<div class="question-list__item-left">
|
||||
<div class="question-list__item-icon">
|
||||
<QuestionCircleOutlined />
|
||||
</div>
|
||||
<div class="question-list__item-content">
|
||||
<strong>{{ question.question_text }}</strong>
|
||||
<div class="mention-rate-container">
|
||||
<span class="mention-rate-label">{{ t('tracking.metrics.mentionRate') }}</span>
|
||||
<span class="mention-rate-badge" :class="getMentionRateClass(question.mention_rate)">
|
||||
{{ formatPercent(question.mention_rate) }}
|
||||
</span>
|
||||
<div class="mention-rate-track">
|
||||
<div
|
||||
class="mention-rate-bar"
|
||||
:style="{ width: formatPercent(question.mention_rate) }"
|
||||
:class="getMentionRateClass(question.mention_rate)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="question-list__right">
|
||||
<div class="question-list__time-wrap">
|
||||
<ClockCircleOutlined class="time-icon" />
|
||||
<span class="question-list__time">
|
||||
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-list__arrow">
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<span class="question-list__time">
|
||||
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1303,29 +1371,45 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
:key="item.article_id"
|
||||
class="article-list__item"
|
||||
>
|
||||
<div class="article-list__content">
|
||||
<strong>{{ item.article_title }}</strong>
|
||||
<p>{{ item.publish_platform }}</p>
|
||||
<div class="article-list__item-left">
|
||||
<div class="article-list__item-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div class="article-list__item-content">
|
||||
<strong>{{ item.article_title }}</strong>
|
||||
<span class="article-platform-tag">{{ item.publish_platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-list__side">
|
||||
<div class="article-list__meta">
|
||||
<span>{{ item.citation_count }} {{ t('tracking.citations') }}</span>
|
||||
<span>
|
||||
{{ t('tracking.columns.share') }} {{ formatPercent(item.source_share) }}
|
||||
</span>
|
||||
<strong>{{ formatPercent(item.citation_rate) }}</strong>
|
||||
<div class="article-metric-group">
|
||||
<div class="article-metric">
|
||||
<span class="article-metric-val">{{ item.citation_count }}</span>
|
||||
<span class="article-metric-lbl">{{ t('tracking.citations') }}</span>
|
||||
</div>
|
||||
<div class="article-metric-divider"></div>
|
||||
<div class="article-metric">
|
||||
<span class="article-metric-val">{{ formatPercent(item.source_share) }}</span>
|
||||
<span class="article-metric-lbl">{{ t('tracking.columns.share') }}</span>
|
||||
</div>
|
||||
<div class="article-metric-divider"></div>
|
||||
<div class="article-metric accent">
|
||||
<span class="article-metric-val">{{ formatPercent(item.citation_rate) }}</span>
|
||||
<span class="article-metric-lbl">引用率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
<a-tooltip :title="t('tracking.viewArticleContent')">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="article-list__preview-button"
|
||||
:aria-label="t('tracking.viewArticleContent')"
|
||||
@click="openCitedArticle(item)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-tooltip :title="t('tracking.viewArticleContent')">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="article-list__preview-button"
|
||||
:aria-label="t('tracking.viewArticleContent')"
|
||||
@click="openCitedArticle(item)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1558,9 +1642,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1569,10 +1653,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #1f5cff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
height: 16px;
|
||||
background: linear-gradient(180deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.tracking-panel__description {
|
||||
@@ -1582,13 +1666,54 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
.tracking-panel__helper {
|
||||
color: var(--muted);
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tracking-list-search {
|
||||
width: min(100%, 260px);
|
||||
flex: 0 0 260px;
|
||||
.tracking-list-search.ant-input-affix-wrapper {
|
||||
background-color: #f3f4f6;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
transition: all 0.25s ease;
|
||||
height: 38px;
|
||||
align-items: center;
|
||||
width: min(100%, 280px);
|
||||
flex: 0 0 280px;
|
||||
}
|
||||
|
||||
.tracking-list-search.ant-input-affix-wrapper:hover {
|
||||
background-color: #e5e7eb;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.tracking-list-search.ant-input-affix-wrapper-focused,
|
||||
.tracking-list-search.ant-input-affix-wrapper:focus {
|
||||
background-color: #ffffff;
|
||||
border-color: #1f5cff;
|
||||
box-shadow: 0 0 0 3px rgba(31, 92, 255, 0.1), 0 4px 12px rgba(31, 92, 255, 0.05);
|
||||
}
|
||||
|
||||
.tracking-list-search :deep(.ant-input) {
|
||||
background-color: transparent !important;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tracking-list-search .search-icon {
|
||||
color: #9ca3af;
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.tracking-list-search.ant-input-affix-wrapper-focused .search-icon {
|
||||
color: #1f5cff;
|
||||
}
|
||||
|
||||
.tracking-panel__empty {
|
||||
@@ -1890,32 +2015,76 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid #f0f3fa;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.question-list__item:hover,
|
||||
.article-list__item:hover,
|
||||
.detail-citation-list__item:hover {
|
||||
background: #ffffff;
|
||||
border-color: #e5eaf2;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
text-align: left;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.question-list__item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-list__item:hover,
|
||||
.article-list__item:hover,
|
||||
.detail-citation-list__item:hover {
|
||||
background: #ffffff;
|
||||
border-color: rgba(31, 92, 255, 0.25);
|
||||
box-shadow: 0 10px 25px -5px rgba(31, 92, 255, 0.08), 0 8px 10px -6px rgba(31, 92, 255, 0.04);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.question-list__item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-list__item-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #1f5cff;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.question-list__item:hover .question-list__item-icon {
|
||||
background: linear-gradient(135deg, #1f5cff 0%, #104df5 100%);
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.question-list__item-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-list__item strong,
|
||||
.article-list__item strong,
|
||||
.detail-citation-list__item strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.question-list__item:hover strong {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.question-list__item p,
|
||||
@@ -1923,39 +2092,218 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
.detail-citation-list__item p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.question-list__time,
|
||||
.article-list__meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-list__meta {
|
||||
/* Mention Rate Badge & Progress Bar */
|
||||
.mention-rate-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mention-rate-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mention-rate-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-zero {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-low {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-mid {
|
||||
background: #fdf2f8;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-high {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.mention-rate-track {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mention-rate-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-zero {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-low {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-mid {
|
||||
background: #ec4899;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-high {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
/* Right-side alignment and arrows */
|
||||
.question-list__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.question-list__time-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 84px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.article-list__content {
|
||||
.time-icon {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.question-list__time {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.question-list__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.question-list__item:hover .question-list__arrow {
|
||||
background: #eff6ff;
|
||||
color: #1f5cff;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.article-list__item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-list__content strong {
|
||||
overflow-wrap: anywhere;
|
||||
.article-list__item-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
color: #16a34a;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.article-list__item:hover .article-list__item-icon {
|
||||
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.article-list__item-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-platform-tag {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.article-list__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-metric-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.article-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.article-metric-val {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.article-metric-lbl {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.article-metric.accent .article-metric-val {
|
||||
color: #1f5cff;
|
||||
}
|
||||
|
||||
.article-metric-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.article-list__preview-button {
|
||||
color: #4b5563;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.article-list__preview-button:hover {
|
||||
@@ -1966,7 +2314,28 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
.tracking-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-item) {
|
||||
border-radius: 6px;
|
||||
border-color: #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-item-active) {
|
||||
background-color: #1f5cff;
|
||||
border-color: #1f5cff;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-item-active a) {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-prev .ant-pagination-item-link),
|
||||
.tracking-pagination :deep(.ant-pagination-next .ant-pagination-item-link) {
|
||||
border-radius: 6px;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
@@ -2062,13 +2431,32 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.question-list__item-left {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.question-list__right {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.question-list__time {
|
||||
align-self: flex-end;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.article-list__item-left {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-list__side {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.article-metric-group {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tracking-citation-header {
|
||||
|
||||
@@ -1722,6 +1722,13 @@ export interface Question {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface QuestionListResponse {
|
||||
items: Question[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface BrandLibrarySummary {
|
||||
plan_code: string
|
||||
plan_name: string
|
||||
|
||||
@@ -333,7 +333,7 @@ var routeDocs = map[string]routeDoc{
|
||||
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
|
||||
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
|
||||
|
||||
"GET /api/tenant/brands/:id/questions": {"搜索词列表", "返回品牌下的监控搜索词,可按 keyword_id 过滤。"},
|
||||
"GET /api/tenant/brands/:id/questions": {"搜索词列表", "分页返回品牌下的监控搜索词,可按 keyword_id 过滤,支持 q 搜索。"},
|
||||
"POST /api/tenant/brands/:id/questions": {"新增搜索词", "为品牌添加一条 GEO 监控搜索词。"},
|
||||
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、精准词、行业词、后缀词组合生成搜索词候选,仅预览不入库。"},
|
||||
"POST /api/tenant/brands/:id/questions/combination-fill": {"拓词工具 AI 填词", "根据用户 IP 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"},
|
||||
|
||||
@@ -474,10 +474,44 @@ type QuestionResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
|
||||
type QuestionListParams struct {
|
||||
KeywordID *int64
|
||||
Page int
|
||||
PageSize int
|
||||
Query string
|
||||
}
|
||||
|
||||
type QuestionListResponse struct {
|
||||
Items []QuestionResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
func normalizeQuestionListParams(params QuestionListParams) QuestionListParams {
|
||||
params.Query = strings.TrimSpace(params.Query)
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 {
|
||||
params.PageSize = 10
|
||||
}
|
||||
if params.PageSize > 100 {
|
||||
params.PageSize = 100
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (params QuestionListParams) offset() int {
|
||||
params = normalizeQuestionListParams(params)
|
||||
return (params.Page - 1) * params.PageSize
|
||||
}
|
||||
|
||||
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, keywordID), defaultCacheTTL(), func(loadCtx context.Context) ([]QuestionResponse, error) {
|
||||
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, keywordID)
|
||||
params = normalizeQuestionListParams(params)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*QuestionListResponse, error) {
|
||||
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, params)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -857,18 +891,44 @@ func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
|
||||
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
|
||||
params = normalizeQuestionListParams(params)
|
||||
|
||||
countQuery := `
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
|
||||
countArgs := []interface{}{brandID, tenantID}
|
||||
if params.KeywordID != nil {
|
||||
countQuery += ` AND q.keyword_id = $3`
|
||||
countArgs = append(countArgs, *params.KeywordID)
|
||||
}
|
||||
if params.Query != "" {
|
||||
countArgs = append(countArgs, "%"+params.Query+"%")
|
||||
countQuery += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(countArgs))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to count questions")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
|
||||
args := []interface{}{brandID, tenantID}
|
||||
|
||||
if keywordID != nil {
|
||||
if params.KeywordID != nil {
|
||||
query += ` AND q.keyword_id = $3`
|
||||
args = append(args, *keywordID)
|
||||
args = append(args, *params.KeywordID)
|
||||
}
|
||||
query += ` ORDER BY q.created_at DESC`
|
||||
if params.Query != "" {
|
||||
args = append(args, "%"+params.Query+"%")
|
||||
query += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(args))
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY q.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, params.PageSize, params.offset())
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -886,7 +946,18 @@ func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID
|
||||
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
if items == nil {
|
||||
items = []QuestionResponse{}
|
||||
}
|
||||
return &QuestionListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) {
|
||||
|
||||
@@ -116,11 +116,17 @@ func brandQuestionsCachePrefix(tenantID, brandID int64) string {
|
||||
return fmt.Sprintf("brand:questions:%d:%d:", tenantID, brandID)
|
||||
}
|
||||
|
||||
func brandQuestionsCacheKey(tenantID, brandID int64, keywordID *int64) string {
|
||||
func brandQuestionsCacheKey(tenantID, brandID int64, params QuestionListParams) string {
|
||||
return fmt.Sprintf("%s%s", brandQuestionsCachePrefix(tenantID, brandID), digestCacheKey(struct {
|
||||
KeywordID *int64 `json:"keyword_id,omitempty"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Query string `json:"q,omitempty"`
|
||||
}{
|
||||
KeywordID: keywordID,
|
||||
KeywordID: params.KeywordID,
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
Query: params.Query,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -3639,7 +3639,9 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
SELECT
|
||||
cf.cited_url,
|
||||
cf.normalized_url
|
||||
cf.normalized_url,
|
||||
NULLIF(cf.article_id, 0),
|
||||
NULLIF(cf.cited_title, '')
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
@@ -3668,22 +3670,40 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
for rows.Next() {
|
||||
var citedURL string
|
||||
var normalizedURL string
|
||||
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
|
||||
var factArticleID sql.NullInt64
|
||||
var factCitedTitle sql.NullString
|
||||
if scanErr := rows.Scan(&citedURL, &normalizedURL, &factArticleID, &factCitedTitle); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
||||
}
|
||||
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||
if !ok {
|
||||
|
||||
articleID := factArticleID.Int64
|
||||
if !factArticleID.Valid && ok {
|
||||
articleID = alias.ArticleID
|
||||
}
|
||||
if articleID <= 0 {
|
||||
continue
|
||||
}
|
||||
item := aggregates[alias.ArticleID]
|
||||
|
||||
item := aggregates[articleID]
|
||||
if item.ArticleID == 0 {
|
||||
item.ArticleID = alias.ArticleID
|
||||
item.ArticleTitle = alias.ArticleTitle
|
||||
item.PublishPlatform = alias.PublishPlatform
|
||||
item.ArticleID = articleID
|
||||
if ok && strings.TrimSpace(alias.ArticleTitle) != "" {
|
||||
item.ArticleTitle = alias.ArticleTitle
|
||||
} else if factCitedTitle.Valid && strings.TrimSpace(factCitedTitle.String) != "" {
|
||||
item.ArticleTitle = factCitedTitle.String
|
||||
} else {
|
||||
item.ArticleTitle = "未命名文章"
|
||||
}
|
||||
if ok && strings.TrimSpace(alias.PublishPlatform) != "" {
|
||||
item.PublishPlatform = alias.PublishPlatform
|
||||
} else {
|
||||
item.PublishPlatform = "已发布内容"
|
||||
}
|
||||
}
|
||||
item.CitationCount++
|
||||
totalArticleCitationCount++
|
||||
aggregates[alias.ArticleID] = item
|
||||
aggregates[articleID] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
|
||||
@@ -3748,11 +3768,12 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
var stats citedArticleStats
|
||||
if err := s.monitoringPool.QueryRow(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT alias.article_id, COUNT(*)::bigint AS citation_count
|
||||
SELECT COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
|
||||
COUNT(*)::bigint AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
JOIN monitoring_article_url_aliases alias
|
||||
LEFT JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id
|
||||
AND alias.normalized_url = cf.normalized_url
|
||||
AND alias.confidence = 'high'
|
||||
@@ -3764,7 +3785,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
GROUP BY alias.article_id
|
||||
AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
|
||||
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
|
||||
)
|
||||
SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint
|
||||
FROM article_counts
|
||||
@@ -3780,14 +3802,14 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT
|
||||
alias.article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
||||
COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), MAX(NULLIF(cf.cited_title, '')), '未命名文章') AS article_title,
|
||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
||||
COUNT(*)::bigint AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
JOIN monitoring_article_url_aliases alias
|
||||
LEFT JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id
|
||||
AND alias.normalized_url = cf.normalized_url
|
||||
AND alias.confidence = 'high'
|
||||
@@ -3799,7 +3821,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
GROUP BY alias.article_id
|
||||
AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
|
||||
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
|
||||
)
|
||||
SELECT article_id, article_title, publish_platform, citation_count
|
||||
FROM article_counts
|
||||
|
||||
@@ -156,14 +156,39 @@ func (h *BrandHandler) DeleteKeyword(c *gin.Context) {
|
||||
|
||||
// Questions
|
||||
func (h *BrandHandler) ListQuestions(c *gin.Context) {
|
||||
brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
brandID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "brand id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
var keywordID *int64
|
||||
if v := c.Query("keyword_id"); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
keywordID = &id
|
||||
id, parseErr := strconv.ParseInt(v, 10, 64)
|
||||
if parseErr != nil || id <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "keyword_id must be a positive integer"))
|
||||
return
|
||||
}
|
||||
keywordID = &id
|
||||
}
|
||||
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, keywordID)
|
||||
|
||||
page, err := parsePositiveIntQuery(c, "page", 1)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
|
||||
return
|
||||
}
|
||||
pageSize, err := parsePositiveIntQuery(c, "page_size", 10)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, app.QuestionListParams{
|
||||
KeywordID: keywordID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Query: c.Query("q"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
@@ -171,6 +196,21 @@ func (h *BrandHandler) ListQuestions(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func parsePositiveIntQuery(c *gin.Context, key string, fallback int) (int, error) {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (h *BrandHandler) CreateQuestion(c *gin.Context) {
|
||||
brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var req app.QuestionRequest
|
||||
|
||||
Reference in New Issue
Block a user