feat: scope articles by brand

This commit is contained in:
2026-05-20 15:37:25 +08:00
parent 5fb9d0b0dd
commit dd082e2ed1
72 changed files with 3213 additions and 432 deletions
@@ -16,6 +16,7 @@ import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, complianceApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
type EditorCanvasPublic = InstanceType<typeof ArticleEditorCanvas> & {
scrollToText?: (text: string) => boolean
@@ -25,6 +26,7 @@ const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const articleId = computed(() => Number(route.params.id))
const title = ref('')
@@ -38,8 +40,10 @@ const editorComplianceResult = ref<ComplianceCheckResult | null>(null)
const editorCanvasRef = ref<EditorCanvasPublic | null>(null)
const detailQuery = useQuery({
queryKey: computed(() => ['articles', 'detail', articleId.value]),
enabled: computed(() => Number.isInteger(articleId.value) && articleId.value > 0),
queryKey: computed(() => ['articles', 'detail', companyStore.currentBrandId, articleId.value]),
enabled: computed(
() => Boolean(companyStore.currentBrandId) && Number.isInteger(articleId.value) && articleId.value > 0,
),
queryFn: () => articlesApi.detail(articleId.value),
})
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -22,14 +22,19 @@ import { useRoute, useRouter } from 'vue-router'
import { brandsApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
const activeTab = ref<'questions' | 'competitors'>('questions')
const selectedBrandId = ref<number | null>(null)
const selectedBrandId = computed<number | null>({
get: () => companyStore.currentBrandId,
set: (brandId) => companyStore.setCurrentBrand(brandId),
})
const brandModalOpen = ref(false)
const editingBrandId = ref<number | null>(null)
@@ -148,6 +153,7 @@ const brandMutations = {
message.success(t('brands.messages.createBrand'))
brandModalOpen.value = false
selectedBrandId.value = brand.id
companyStore.setCurrentBrand(brand.id)
await invalidateBrandQueries()
},
onError: (error) => message.error(formatError(error)),
@@ -320,6 +326,14 @@ function openBrandModal(brand?: Brand): void {
brandModalOpen.value = true
}
function selectBrand(brandId: number): void {
if (!Number.isFinite(brandId) || brandId <= 0) {
return
}
selectedBrandId.value = brandId
void queryClient.invalidateQueries({ queryKey: ['brands'] })
}
function ensureQuestionCreatable(): boolean {
if (!selectedBrandId.value) {
message.warning(t('brands.messages.chooseBrand'))
@@ -414,6 +428,7 @@ async function invalidateBrandQueries(): Promise<void> {
queryClient.invalidateQueries({ queryKey: ['brands'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
await companyStore.refreshBrands()
}
</script>
@@ -440,7 +455,7 @@ async function invalidateBrandQueries(): Promise<void> {
:key="brand.id"
class="brand-card"
:class="{ 'brand-card--active': brand.id === selectedBrandId }"
@click="selectedBrandId = brand.id"
@click="selectBrand(brand.id)"
>
<div class="brand-card__top">
<div>
+14 -2
View File
@@ -20,10 +20,12 @@ import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { copyTextToClipboard } from '@/lib/clipboard'
import { getPublishStatusMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const router = useRouter()
const { t } = useI18n()
const companyStore = useCompanyStore()
const publishModalOpen = ref(false)
const selectedPublishArticleId = ref<number | null>(null)
@@ -75,7 +77,8 @@ const articleParams = computed<ArticleListParams>(() => {
})
const articleListQuery = useQuery({
queryKey: computed(() => ['articles', 'list', articleParams.value]),
queryKey: computed(() => ['articles', 'list', companyStore.currentBrandId, articleParams.value]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => articlesApi.list(articleParams.value),
})
@@ -179,6 +182,10 @@ async function handleDelete(articleId: number): Promise<void> {
}
function handleCreate(): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
createMutation.mutate()
}
@@ -209,7 +216,12 @@ async function copyArticle(articleId: number): Promise<void> {
<p>{{ t('route.freeCreate.description') }}</p>
</div>
<div class="free-create-view__header-actions">
<a-button type="primary" :loading="createMutation.isPending.value" @click="handleCreate">
<a-button
type="primary"
:disabled="!companyStore.currentBrandId"
:loading="createMutation.isPending.value"
@click="handleCreate"
>
<template #icon><PlusOutlined /></template>
{{ t('freeCreate.actions.create') }}
</a-button>
@@ -9,17 +9,18 @@ import { useRoute, useRouter } from 'vue-router'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
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',
brand_name: '',
region: '',
keywords: [] as string[],
preserve_points: '',
@@ -29,26 +30,14 @@ const form = reactive({
knowledge_group_ids: [] as number[],
})
const brandsQuery = useQuery({
queryKey: ['brands'],
queryFn: () => brandsApi.list(),
})
const brandNameOptions = computed(() =>
(brandsQuery.data.value ?? []).map((item) => ({
label: item.name,
value: item.name,
})),
)
const selectedBrand = computed(
() => brandsQuery.data.value?.find((item) => item.name === form.brand_name.trim()) ?? null,
)
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', selectedBrand.value?.id, 'questions', 'imitation']),
enabled: computed(() => Boolean(selectedBrand.value?.id)),
queryFn: () => brandsApi.listQuestions(selectedBrand.value?.id as number),
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'imitation']),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const keywordOptions = computed(() =>
@@ -61,7 +50,7 @@ const keywordOptions = computed(() =>
const canSubmit = computed(
() =>
form.source_url.trim().length > 0 &&
form.brand_name.trim().length > 0 &&
currentBrandName.value.length > 0 &&
!generateMutation.isPending.value,
)
@@ -71,7 +60,7 @@ const generateMutation = useMutation({
source_url: form.source_url.trim(),
source_title: form.source_title.trim(),
locale: form.locale,
brand_name: form.brand_name.trim(),
brand_name: currentBrandName.value,
region: form.region.trim(),
keywords: form.keywords.map((item) => item.trim()).filter(Boolean),
preserve_points: form.preserve_points.trim(),
@@ -117,7 +106,7 @@ function submit(): void {
message.warning(t('imitation.create.sourceURLRequired'))
return
}
if (!form.brand_name.trim()) {
if (!currentBrandName.value) {
message.warning(t('imitation.create.brandNameRequired'))
return
}
@@ -231,12 +220,10 @@ function submit(): void {
<div class="imitation-generate-page__field-grid">
<div class="imitation-generate-page__field">
<label class="required-asterisk">{{ t('imitation.create.brandName') }}</label>
<a-auto-complete
v-model:value="form.brand_name"
allow-clear
:options="brandNameOptions"
:placeholder="t('imitation.create.brandNamePlaceholder')"
/>
<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">
@@ -508,6 +495,37 @@ function submit(): void {
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;
+9 -2
View File
@@ -20,10 +20,12 @@ import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/a
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { getGenerateStatusMeta, getPublishStatusMeta, hasActiveGenerationStatus } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const router = useRouter()
const { t } = useI18n()
const companyStore = useCompanyStore()
const publishModalOpen = ref(false)
const selectedPublishArticleId = ref<number | null>(null)
@@ -80,7 +82,8 @@ const articleParams = computed<ArticleListParams>(() => {
})
const articleListQuery = useQuery({
queryKey: computed(() => ['articles', 'list', articleParams.value]),
queryKey: computed(() => ['articles', 'list', companyStore.currentBrandId, articleParams.value]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => articlesApi.list(articleParams.value),
})
@@ -205,6 +208,10 @@ function resetFilters(): void {
}
function openCreate(): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
void router.push({ name: 'article-imitation-create' })
}
@@ -278,7 +285,7 @@ async function copyArticle(articleId: number): Promise<void> {
<p>{{ t('route.imitation.description') }}</p>
</div>
<div class="imitation-view__header-actions">
<a-button type="primary" @click="openCreate">
<a-button type="primary" :disabled="!companyStore.currentBrandId" @click="openCreate">
<template #icon><PlusOutlined /></template>
{{ t('imitation.actions.create') }}
</a-button>
@@ -9,6 +9,7 @@ import {
isKolVariableValueEmpty,
normalizeKolVariableDefinition,
} from '@/lib/kol-placeholders'
import { useCompanyStore } from '@/stores/company'
import { LeftOutlined } from '@ant-design/icons-vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
@@ -20,6 +21,7 @@ const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const subscriptionPromptId = computed(() => String(route.params.subscriptionPromptId))
const sourcePage = computed<'workspace' | 'templates' | null>(() => {
@@ -106,6 +108,10 @@ function handleBack() {
function handleSubmit() {
const schemaValue = schemaQuery.data.value
if (!schemaValue?.schema_json?.variables) return
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
const missingLabels: string[] = []
schemaValue.schema_json.variables.forEach((v) => {
+10 -1
View File
@@ -97,10 +97,12 @@ import { useRoute, useRouter } from 'vue-router'
import AnimatedCharacters from '@/components/login-animation/AnimatedCharacters.vue'
import { formatError } from '@/lib/errors'
import { useAuthStore } from '@/stores/auth'
import { useCompanyStore } from '@/stores/company'
const REMEMBER_STORAGE_KEY = 'geo:admin:login:remember'
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
@@ -131,6 +133,9 @@ onMounted(() => {
formState.identifier = parsed.identifier
remember.value = true
}
if (parsed && typeof parsed.password === 'string') {
formState.password = parsed.password
}
} catch {
window.localStorage.removeItem(REMEMBER_STORAGE_KEY)
}
@@ -143,7 +148,10 @@ function persistRememberedCredentials(): void {
if (remember.value) {
window.localStorage.setItem(
REMEMBER_STORAGE_KEY,
JSON.stringify({ identifier: formState.identifier }),
JSON.stringify({
identifier: formState.identifier,
password: formState.password,
}),
)
} else {
window.localStorage.removeItem(REMEMBER_STORAGE_KEY)
@@ -159,6 +167,7 @@ async function handleSubmit(): Promise<void> {
try {
await authStore.login(formState)
companyStore.reset()
persistRememberedCredentials()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/workspace'
await router.replace(redirect)
@@ -20,6 +20,7 @@ import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { resolvePlatformLogoUrl } from '@/lib/publish-account-cards'
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 52
@@ -307,6 +308,7 @@ function buildDesktopWorkbenchUrl(record: {
const { t } = useI18n()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const page = ref(1)
const searchTitle = ref('')
const appliedTitle = ref('')
@@ -321,8 +323,10 @@ const tasksQuery = useQuery({
queryKey: computed(() => [
'tenant',
'publish-tasks',
companyStore.currentBrandId,
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () =>
publishTasksApi.list({
page: page.value,
+67 -65
View File
@@ -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 { useCompanyStore } from '@/stores/company'
interface DraftCompetitor {
key: string
@@ -151,6 +152,7 @@ const queryClient = useQueryClient()
const { locale: uiLocale, t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
let competitorKeySeed = 0
let outlineNodeSeed = 0
@@ -167,8 +169,6 @@ const savingDraft = ref(false)
const leaveConfirmOpen = ref(false)
const leaveConfirmBusy = ref(false)
const selectedBrandId = ref<number | null>(null)
const brandName = ref('')
const officialWebsite = ref('')
const brandSummary = ref('')
@@ -209,11 +209,9 @@ const templateDetailQuery = useQuery({
queryFn: () => templatesApi.detail(templateId.value),
})
const brandsQuery = useQuery({
queryKey: ['brands', 'wizard-context'],
enabled,
queryFn: () => brandsApi.list(),
})
const selectedBrand = computed(() => companyStore.currentBrand)
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
const normalizedBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
const draftArticleQuery = useQuery({
queryKey: computed(() => ['articles', 'detail', draftArticleId.value]),
@@ -283,15 +281,6 @@ const templateMeta = computed(() => {
helper: templateCardConfig.value.hero?.helper || fallback.helper,
}
})
const selectedBrand = computed(
() => brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
)
const brandNameOptions = computed(() =>
(brandsQuery.data.value ?? []).map((item) => ({
label: item.name,
value: item.name,
})),
)
function findQuestionById(id: number | null): Question | null {
if (!id) {
@@ -323,9 +312,6 @@ const selectedQuestionTexts = computed(() =>
dedupeStrings([primaryQuestionText.value, ...supplementalQuestionTexts.value]),
)
const normalizedBrandName = computed(
() => brandName.value.trim() || selectedBrand.value?.name || '',
)
const finalTitle = computed(() => customTitle.value.trim() || selectedTitle.value.trim())
const primaryKeyword = computed(() => primaryQuestionText.value)
const showCompetitorsCard = computed(
@@ -455,7 +441,6 @@ const isPageLoading = computed(
const hasWizardDraftContent = computed(
() =>
Boolean(selectedBrandId.value) ||
Boolean(brandName.value.trim()) ||
Boolean(officialWebsite.value.trim()) ||
Boolean(brandSummary.value.trim()) ||
Boolean(primaryQuestionId.value) ||
@@ -504,10 +489,7 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
currentArticleId.value = draftArticleId.value
currentStep.value = 0
articleLocale.value = 'zh-CN'
selectedBrandId.value = null
brandName.value = ''
officialWebsite.value = ''
brandSummary.value = ''
hydrateBrandContextFromCurrentBrand()
primaryQuestionId.value = null
supplementalQuestionIds.value = []
competitorDrafts.value = []
@@ -526,9 +508,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
detail.template_key,
buildTemplateRenderContext({
locale: 'zh-CN',
brandName: '',
officialWebsite: '',
brandSummary: '',
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
@@ -542,9 +524,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
detail.template_key,
buildTemplateRenderContext({
locale: 'zh-CN',
brandName: '',
officialWebsite: '',
brandSummary: '',
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
@@ -565,10 +547,9 @@ function applyDraftArticleState(detail: ArticleDetail): void {
currentStep.value = clampStep(numberFromUnknown(draftState.current_step))
articleLocale.value = stringFromUnknown(draftState.locale) || articleLocale.value
selectedBrandId.value = numberFromUnknown(draftState.selected_brand_id) ?? null
brandName.value = stringFromUnknown(draftState.brand_name) || ''
officialWebsite.value = stringFromUnknown(draftState.official_website) || ''
brandSummary.value = stringFromUnknown(draftState.brand_summary) || ''
hydrateBrandContextFromCurrentBrand()
officialWebsite.value = stringFromUnknown(draftState.official_website) || officialWebsite.value
brandSummary.value = stringFromUnknown(draftState.brand_summary) || brandSummary.value
primaryQuestionId.value = numberFromUnknown(draftState.primary_question_id) ?? null
supplementalQuestionIds.value =
numberListFromUnknown(draftState.supplemental_question_ids)?.slice(0, 3) ?? []
@@ -604,6 +585,14 @@ watch(
{ immediate: true },
)
watch(
() => selectedBrand.value,
() => {
hydrateBrandContextFromCurrentBrand()
},
{ immediate: true },
)
watch(
baseOutlineOptions,
(options) => {
@@ -660,23 +649,6 @@ watch(supplementalQuestionIds, (ids) => {
}
})
function handleBrandNameInput(value: string): void {
brandName.value = value
const matchedBrand = brandsQuery.data.value?.find((item) => item.name === value)
selectedBrandId.value = matchedBrand?.id ?? null
}
function handleBrandNameSelect(value: string): void {
const brand = brandsQuery.data.value?.find((item) => item.name === value)
if (!brand) {
selectedBrandId.value = null
brandName.value = value
return
}
selectedBrandId.value = brand.id
brandName.value = brand.name
}
watch(selectedBrandId, async (brandId) => {
if (restoringDraft.value) {
return
@@ -690,10 +662,7 @@ watch(selectedBrandId, async (brandId) => {
return
}
const brand = brandsQuery.data.value?.find((item) => item.id === brandId)
if (brand && !brandName.value.trim()) {
brandName.value = brand.name
}
const brand = selectedBrand.value
if (brand?.website && !officialWebsite.value.trim()) {
officialWebsite.value = brand.website
}
@@ -719,7 +688,13 @@ watch(selectedBrandId, async (brandId) => {
description: item.description ?? '',
saved: true,
}))
})
}, { immediate: true })
function hydrateBrandContextFromCurrentBrand(): void {
const brand = selectedBrand.value
officialWebsite.value = brand?.website ?? ''
brandSummary.value = brand?.description ?? ''
}
onBeforeRouteLeave(async () => {
if (!shouldConfirmBeforeLeavingWizard()) {
@@ -1310,7 +1285,7 @@ function buildDraftState(): Record<string, JsonValue> {
current_step: currentStep.value,
locale: articleLocale.value,
selected_brand_id: selectedBrandId.value,
brand_name: brandName.value.trim(),
brand_name: normalizedBrandName.value,
official_website: officialWebsite.value.trim(),
brand_summary: brandSummary.value.trim(),
primary_question_id: primaryQuestionId.value,
@@ -2420,14 +2395,10 @@ function onStructureDragEnd(): void {
<label class="required-asterisk">
{{ t('templates.wizard.sections.brandName') }}
</label>
<a-auto-complete
v-model:value="brandName"
allow-clear
:options="brandNameOptions"
:placeholder="t('templates.wizard.placeholders.brandName')"
@change="handleBrandNameInput"
@select="handleBrandNameSelect"
/>
<div class="current-brand-field">
<strong>{{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }}</strong>
<span v-if="selectedBrand?.website">{{ selectedBrand.website }}</span>
</div>
</div>
<div class="field-item">
@@ -3201,6 +3172,37 @@ function onStructureDragEnd(): void {
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;
}
.optional-field-label {
display: flex;
align-items: center;
+24 -3
View File
@@ -42,10 +42,12 @@ import {
getTemplateMeta,
} from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const router = useRouter()
const { t } = useI18n()
const companyStore = useCompanyStore()
const pickerOpen = ref(false)
const pickerMode = ref<'general' | 'refined' | null>(null)
@@ -90,7 +92,8 @@ const kolCardsQuery = useQuery({
})
const recentArticlesQuery = useQuery({
queryKey: ['workspace', 'recent-articles', 'templates'],
queryKey: computed(() => ['workspace', 'recent-articles', 'templates', companyStore.currentBrandId]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => workspaceApi.recentArticles(),
})
@@ -131,7 +134,8 @@ const articleParams = computed<ArticleListParams>(() => {
})
const articleListQuery = useQuery({
queryKey: computed(() => ['articles', 'list', articleParams.value]),
queryKey: computed(() => ['articles', 'list', companyStore.currentBrandId, articleParams.value]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => articlesApi.list(articleParams.value),
})
@@ -330,6 +334,10 @@ function resetFilters(): void {
}
function openTemplatePicker(): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
pickerOpen.value = true
pickerMode.value = 'general'
}
@@ -339,12 +347,20 @@ function choosePickerMode(mode: 'general' | 'refined'): void {
}
function startTemplate(template: TemplateListItem): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
pickerOpen.value = false
pickerMode.value = null
void router.push({ path: '/articles/wizard', query: { template_id: String(template.id) } })
}
function openRefinedTemplate(card: KolWorkspaceCard): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
pickerOpen.value = false
pickerMode.value = null
void router.push({
@@ -471,7 +487,12 @@ function refreshRecords(): void {
<p>{{ t('route.templates.description') }}</p>
</div>
<div class="templates-view__header-actions">
<a-button type="primary" class="templates-view__create-btn" @click="openTemplatePicker">
<a-button
type="primary"
class="templates-view__create-btn"
:disabled="!companyStore.currentBrandId"
@click="openTemplatePicker"
>
<template #icon><PlusOutlined /></template>
{{ t('templates.actions.chooseTemplate') }}
</a-button>
@@ -17,15 +17,18 @@ import { monitoringApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { isMonitoringPlatformId, normalizeMonitoringPlatformId } from '@/lib/monitoring-platforms'
import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
const trackingMaxHistoryDays = 30
const trackingToday = dayjs().format('YYYY-MM-DD')
const brandId = computed(() => parsePositiveNumber(route.params.brandId))
const routeBrandId = computed(() => parsePositiveNumber(route.params.brandId))
const brandId = computed(() => companyStore.currentBrandId ?? routeBrandId.value)
const questionId = computed(() => parsePositiveNumber(route.params.questionId))
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash))
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text))
+4 -43
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import type {
Brand,
DesktopAccountInfo,
MonitoringHotQuestion,
MonitoringPlatformAuthorizationStatus,
@@ -25,10 +24,12 @@ import {
normalizeMonitoringPlatformFilter,
normalizeMonitoringPlatformId,
} from '@/lib/monitoring-platforms'
import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
const trackingMaxHistoryDays = 30
const trackingBusinessTimeZone = 'Asia/Shanghai'
@@ -98,7 +99,6 @@ const trackingPlatformOptions = [
] as const
const allQuestionOptionValue = 'all'
const selectedBrandId = useStorage<number | null>('tracking_selected_brand', null)
const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null)
const selectedPlatformId = useStorage<string>('tracking_selected_ai_platform_id', 'all')
const selectedBusinessDate = useStorage<string>('tracking_selected_business_date', trackingToday)
@@ -117,32 +117,8 @@ const selectedQuestionSetOptionValue = computed<string | number>({
},
})
const brandsQuery = useQuery({
queryKey: ['tracking', 'brands'],
queryFn: () => brandsApi.list(),
})
watch(
() => brandsQuery.data.value,
(brands) => {
if (!brands?.length) {
return
}
const requestedBrandId = parseNumericQuery(route.query.brand_id)
if (requestedBrandId && brands.some((item) => item.id === requestedBrandId)) {
selectedBrandId.value = requestedBrandId
return
}
if (selectedBrandId.value && brands.some((item) => item.id === selectedBrandId.value)) {
return
}
selectedBrandId.value = brands[0].id
},
{ immediate: true },
)
const selectedBrandId = computed(() => companyStore.currentBrandId)
const selectedBrand = computed(() => companyStore.currentBrand)
const questionsQuery = useQuery({
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
@@ -325,10 +301,6 @@ const collectNowMutation = useMutation({
},
})
const selectedBrand = computed<Brand | null>(() => {
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null
})
const currentUserClientOnline = computed(() => {
return dashboardQuery.data.value?.runtime.current_user_client_online === true
})
@@ -850,17 +822,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
:options="trackingPlatformOptions"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">品牌</span>
<a-select
v-model:value="selectedBrandId"
class="tracking-select"
:placeholder="t('tracking.brandPlaceholder')"
:options="
(brandsQuery.data.value ?? []).map((item) => ({ label: item.name, value: item.id }))
"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">关键词库搜索词</span>
<a-select
+47 -7
View File
@@ -32,9 +32,11 @@ import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { formatDateTime, getTemplateMeta, hasActiveGenerationStatus } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { isMediaPublishAccount } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const router = useRouter()
const companyStore = useCompanyStore()
const { t } = useI18n()
const selectedArticleId = ref<number | null>(null)
const articleDrawerOpen = ref(false)
@@ -43,16 +45,18 @@ const publishModalOpen = ref(false)
let recentArticlesPollingTimer: number | null = null
const overviewQuery = useQuery({
queryKey: ['workspace', 'overview'],
queryKey: computed(() => ['workspace', 'overview', companyStore.currentBrandId]),
queryFn: () => workspaceApi.overview(),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
})
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'workspace-overview'],
queryFn: () => tenantAccountsApi.list(),
})
const recentArticlesQuery = useQuery({
queryKey: ['workspace', 'recent-articles'],
queryKey: computed(() => ['workspace', 'recent-articles', companyStore.currentBrandId]),
queryFn: () => workspaceApi.recentArticles(),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
})
const templateCardsQuery = useQuery({
queryKey: ['workspace', 'template-cards'],
@@ -177,6 +181,10 @@ function resolveTemplateIcon(template: TemplateCard): unknown {
}
function openTemplate(template: TemplateCard): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
void router.push({
path: '/articles/wizard',
query: { template_id: String(template.id), from: 'workspace' },
@@ -184,6 +192,10 @@ function openTemplate(template: TemplateCard): void {
}
function openRefinedTemplate(card: KolWorkspaceCard): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
void router.push({
name: 'kol-generate',
params: { subscriptionPromptId: String(card.subscription_prompt_id) },
@@ -192,16 +204,28 @@ function openRefinedTemplate(card: KolWorkspaceCard): void {
}
function openArticle(articleId: number): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
selectedArticleId.value = articleId
articleDrawerOpen.value = true
}
function openPublish(articleId: number): void {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
selectedPublishArticleId.value = articleId
publishModalOpen.value = true
}
async function openEditor(article: RecentArticle): Promise<void> {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
try {
const detail = await articlesApi.detail(article.id)
@@ -228,6 +252,10 @@ async function openEditor(article: RecentArticle): Promise<void> {
}
async function copyArticle(articleId: number): Promise<void> {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
try {
const detail = await articlesApi.detail(articleId)
const content = buildArticleClipboardContent(detail)
@@ -245,17 +273,23 @@ async function copyArticle(articleId: number): Promise<void> {
}
async function handleDelete(articleId: number): Promise<void> {
if (!companyStore.currentBrandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return
}
await deleteMutation.mutateAsync(articleId)
}
function refreshDashboard(): void {
void Promise.all([
overviewQuery.refetch(),
const refreshes: Promise<unknown>[] = [
accountsQuery.refetch(),
recentArticlesQuery.refetch(),
templateCardsQuery.refetch(),
kolCardsQuery.refetch(),
])
]
if (companyStore.currentBrandId) {
refreshes.push(overviewQuery.refetch(), recentArticlesQuery.refetch())
}
void Promise.all(refreshes)
}
watch(
@@ -285,7 +319,13 @@ function startRecentArticlesPolling(): void {
}
recentArticlesPollingTimer = window.setInterval(() => {
void queryClient.invalidateQueries({ queryKey: ['workspace', 'recent-articles'] })
if (!companyStore.currentBrandId) {
stopRecentArticlesPolling()
return
}
void queryClient.invalidateQueries({
queryKey: ['workspace', 'recent-articles', companyStore.currentBrandId],
})
void queryClient.invalidateQueries({ queryKey: ['articles'] })
void recentArticlesQuery.refetch()
}, 3000)