diff --git a/apps/admin-web/src/components/ArticleDetailDrawer.vue b/apps/admin-web/src/components/ArticleDetailDrawer.vue index 7cbdde5..ea530e2 100644 --- a/apps/admin-web/src/components/ArticleDetailDrawer.vue +++ b/apps/admin-web/src/components/ArticleDetailDrawer.vue @@ -22,6 +22,7 @@ import { getSourceTypeLabel, hasActiveGenerationStatus, } from '@/lib/display' +import { useCompanyStore } from '@/stores/company' const props = defineProps<{ open: boolean @@ -34,6 +35,7 @@ const emit = defineEmits<{ const queryClient = useQueryClient() const { t } = useI18n() +const companyStore = useCompanyStore() const activeTab = ref('content') const enabled = computed(() => props.open && Boolean(props.articleId)) const streamTitle = ref('') @@ -42,20 +44,25 @@ const streamStatus = ref(null) const streamFeatureEnabled = isGenerationStreamEnabled() const detailQuery = useQuery({ - queryKey: computed(() => ['articles', 'detail', props.articleId]), - enabled, + queryKey: computed(() => ['articles', 'detail', companyStore.currentBrandId, props.articleId]), + enabled: computed(() => enabled.value && Boolean(companyStore.currentBrandId)), queryFn: () => articlesApi.detail(props.articleId as number), }) const versionsQuery = useQuery({ - queryKey: computed(() => ['articles', 'versions', props.articleId]), - enabled, + queryKey: computed(() => ['articles', 'versions', companyStore.currentBrandId, props.articleId]), + enabled: computed(() => enabled.value && Boolean(companyStore.currentBrandId)), queryFn: () => articlesApi.versions(props.articleId as number), }) const publishRecordsQuery = useQuery({ - queryKey: computed(() => ['articles', 'publish-records', props.articleId]), - enabled, + queryKey: computed(() => [ + 'articles', + 'publish-records', + companyStore.currentBrandId, + props.articleId, + ]), + enabled: computed(() => enabled.value && Boolean(companyStore.currentBrandId)), queryFn: () => articlesApi.publishRecords(props.articleId as number), }) diff --git a/apps/admin-web/src/components/ArticlePublishStatus.vue b/apps/admin-web/src/components/ArticlePublishStatus.vue index 408a4c8..7ccbe1b 100644 --- a/apps/admin-web/src/components/ArticlePublishStatus.vue +++ b/apps/admin-web/src/components/ArticlePublishStatus.vue @@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n' import { articlesApi, mediaApi } from '@/lib/api' import { getPublishStatusMeta } from '@/lib/display' import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms' +import { useCompanyStore } from '@/stores/company' interface PublishPlatformEntry { key: string @@ -42,6 +43,7 @@ const props = withDefaults( ) const { t } = useI18n() +const companyStore = useCompanyStore() const popoverOpen = ref(false) const shouldLoadRecords = computed(() => Boolean(props.articleId) && props.status !== 'unpublished') const shouldLoadPlatforms = computed(() => props.showPlatforms && shouldLoadRecords.value) @@ -54,8 +56,13 @@ const platformsQuery = useQuery({ }) const publishRecordsQuery = useQuery({ - queryKey: computed(() => ['articles', 'publish-records', props.articleId]), - enabled: shouldLoadRecords, + queryKey: computed(() => [ + 'articles', + 'publish-records', + companyStore.currentBrandId, + props.articleId, + ]), + enabled: computed(() => shouldLoadRecords.value && Boolean(companyStore.currentBrandId)), queryFn: () => articlesApi.publishRecords(props.articleId as number), staleTime: 30 * 1000, }) diff --git a/apps/admin-web/src/components/CustomArticleTab.vue b/apps/admin-web/src/components/CustomArticleTab.vue index d1aa34d..c17e9a7 100644 --- a/apps/admin-web/src/components/CustomArticleTab.vue +++ b/apps/admin-web/src/components/CustomArticleTab.vue @@ -19,10 +19,12 @@ import { useArticleRegenerateAction } from '@/lib/article-regenerate-action' import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display' import { formatError } from '@/lib/errors' import { formatPublishPlatformList } from '@/lib/publish-platforms' +import { useCompanyStore } from '@/stores/company' const queryClient = useQueryClient() const router = useRouter() const { t } = useI18n() +const companyStore = useCompanyStore() const page = ref(1) const pageSize = ref(20) @@ -93,7 +95,14 @@ const articleParams = computed(() => { }) const listQuery = useQuery({ - queryKey: computed(() => ['articles', 'list', 'custom_generation', articleParams.value]), + queryKey: computed(() => [ + 'articles', + 'list', + companyStore.currentBrandId, + 'custom_generation', + articleParams.value, + ]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => articlesApi.list(articleParams.value), }) diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue index 6a393e8..198c795 100644 --- a/apps/admin-web/src/components/GenerateTaskDrawer.vue +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -25,6 +25,7 @@ import { publishStatusShortLabel, type PublishAccountCard, } from '@/lib/publish-account-cards' +import { useCompanyStore } from '@/stores/company' const props = defineProps<{ open: boolean @@ -38,6 +39,7 @@ const emit = defineEmits<{ const queryClient = useQueryClient() const { t } = useI18n() +const companyStore = useCompanyStore() const promptDrawerOpen = ref(false) const coverPickerOpen = ref(false) @@ -108,6 +110,7 @@ const selectedSchedulePublishPlatformIds = computed(() => { const scheduleCoverRequired = computed(() => coverUploadRequired(selectedSchedulePublishPlatformIds.value), ) +const currentBrandId = computed(() => companyStore.currentBrandId) const effectiveScheduleCoverEnabled = computed( () => scheduleCoverRequired.value || form.coverEnabled, ) @@ -207,6 +210,7 @@ function buildSchedulePayload() { return { name: form.name.trim(), prompt_rule_id: form.promptRuleId!, + brand_id: currentBrandId.value, cron_expr: buildDailyCron(form.scheduleTime), auto_publish: form.autoPublish, publish_account_ids: form.autoPublish ? form.publishAccountIds : [], @@ -220,6 +224,7 @@ function buildSchedulePayload() { function buildInstantPayload() { return { prompt_rule_id: form.promptRuleId!, + brand_id: currentBrandId.value ?? undefined, extra_params: { task_name: form.name.trim(), generation_mode: 'instant', @@ -287,6 +292,11 @@ function handleScheduleCoverToggle(checked: boolean): void { } function validateForm(): boolean { + if (!currentBrandId.value) { + message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion')) + return false + } + if (!form.name.trim()) { message.warning(t('custom.messages.missingTaskName')) return false diff --git a/apps/admin-web/src/components/InstantTaskTab.vue b/apps/admin-web/src/components/InstantTaskTab.vue index 0013911..e782d49 100644 --- a/apps/admin-web/src/components/InstantTaskTab.vue +++ b/apps/admin-web/src/components/InstantTaskTab.vue @@ -11,8 +11,10 @@ import GeneratedArticleLinksDrawer from '@/components/GeneratedArticleLinksDrawe import { instantTasksApi, promptRulesApi } from '@/lib/api' import { formatDateTime, getGenerateStatusMeta } from '@/lib/display' import { formatPublishPlatformList } from '@/lib/publish-platforms' +import { useCompanyStore } from '@/stores/company' const { t } = useI18n() +const companyStore = useCompanyStore() const page = ref(1) const pageSize = ref(20) @@ -72,7 +74,8 @@ const listParams = computed(() => { }) const listQuery = useQuery({ - queryKey: computed(() => ['instantTasks', 'list', listParams.value]), + queryKey: computed(() => ['instantTasks', 'list', companyStore.currentBrandId, listParams.value]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => instantTasksApi.list(listParams.value), }) diff --git a/apps/admin-web/src/components/PublishArticleModal.vue b/apps/admin-web/src/components/PublishArticleModal.vue index 911c288..f251b04 100644 --- a/apps/admin-web/src/components/PublishArticleModal.vue +++ b/apps/admin-web/src/components/PublishArticleModal.vue @@ -41,6 +41,7 @@ import { publishStateLabel, type PublishAccountCard, } from '@/lib/publish-account-cards' +import { useCompanyStore } from '@/stores/company' const props = defineProps<{ open: boolean @@ -54,6 +55,7 @@ const emit = defineEmits<{ const { t } = useI18n() const queryClient = useQueryClient() +const companyStore = useCompanyStore() const selectedAccountIds = ref([]) const coverEnabled = ref(false) @@ -71,8 +73,16 @@ let publishAdmissionRunId = 0 const ackInProgress = ref(false) const detailQuery = useQuery({ - queryKey: computed(() => ['articles', 'detail', props.articleId, 'publish-modal']), - enabled: computed(() => props.open && Boolean(props.articleId)), + queryKey: computed(() => [ + 'articles', + 'detail', + companyStore.currentBrandId, + props.articleId, + 'publish-modal', + ]), + enabled: computed( + () => props.open && Boolean(props.articleId) && Boolean(companyStore.currentBrandId), + ), queryFn: () => articlesApi.detail(props.articleId as number), }) diff --git a/apps/admin-web/src/components/ScheduleTaskTab.vue b/apps/admin-web/src/components/ScheduleTaskTab.vue index 2b92dc8..2b4a559 100644 --- a/apps/admin-web/src/components/ScheduleTaskTab.vue +++ b/apps/admin-web/src/components/ScheduleTaskTab.vue @@ -20,9 +20,11 @@ import { promptRulesApi, schedulesApi } from '@/lib/api' import { formatDateTime, getGenerateStatusMeta, hasActiveGenerationStatus } from '@/lib/display' import { formatError } from '@/lib/errors' import { formatPublishPlatformList } from '@/lib/publish-platforms' +import { useCompanyStore } from '@/stores/company' const queryClient = useQueryClient() const { t } = useI18n() +const companyStore = useCompanyStore() const modalOpen = ref(false) const editingTask = ref(null) @@ -81,7 +83,8 @@ const listParams = computed(() => { }) const listQuery = useQuery({ - queryKey: computed(() => ['schedules', 'list', listParams.value]), + queryKey: computed(() => ['schedules', 'list', companyStore.currentBrandId, listParams.value]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => schedulesApi.list(listParams.value), }) diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 900b75a..b7ce8a5 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -99,7 +99,7 @@ const enUS = { email: 'Email', loginIdentifier: 'Phone / Email', password: 'Password', - rememberAccount: 'Remember account', + rememberAccount: 'Remember account and password', forgotPassword: 'Forgot password', }, membershipBlocked: { @@ -137,6 +137,9 @@ const enUS = { userFallback: 'Admin', logout: 'Logout', planFallback: 'Free Plan', + currentCompany: 'Company', + currentCompanyPlaceholder: 'Select company', + createCompany: 'Create company', openNavigation: 'Open navigation', collapseNavigation: 'Collapse navigation', expandNavigation: 'Expand navigation', @@ -169,6 +172,10 @@ const enUS = { title: 'Welcome back', description: 'Sign in with a real tenant account.', }, + brandOnboarding: { + title: 'Choose a brand', + description: 'Create the first brand and initialize its keyword library.', + }, workspace: { title: 'Workspace', description: 'Template shortcuts, overview data, and recent generations in one place.', @@ -250,6 +257,47 @@ const enUS = { description: 'The link you opened might have moved or no longer exists.', }, }, + onboardingBrand: { + eyebrow: 'Brand Onboarding', + title: 'Choose a brand or company', + subtitle: 'You have access to multiple brands or companies. Choose one to start working.', + createBrand: 'Create new brand or company', + createHint: 'After creation, keyword initialization starts automatically.', + emptyDescription: + 'New users need to create a brand or company before entering the workspace.', + noWebsite: 'No website yet', + form: { + title: 'Create brand or company', + name: 'Brand or company name', + namePlaceholder: 'Example: GEO Console / Acme Furniture', + website: 'Website (optional)', + websitePlaceholder: 'https://example.com', + description: 'Description', + descriptionPlaceholder: 'Briefly describe the business, products, service area, or core value.', + }, + ai: { + title: 'Generating brand or company questions', + stages: { + fetch: 'Fetching website and business context...', + generate: 'Generating candidate search terms for monitoring...', + refine: 'Deduplicating and filtering savable questions...', + }, + }, + questions: { + title: 'Select brand or company questions', + subtitle: + 'Choose up to {limit} questions to save to the keyword library. You can add or edit more later.', + confirm: 'Save and enter workspace', + skip: 'Skip and enter', + empty: 'No usable questions were generated. You can enter the workspace and add them manually.', + selectionLimit: 'Choose up to {limit} questions.', + }, + messages: { + nameRequired: 'Enter a brand or company name.', + questionRequired: 'Select at least one question, or skip and add them manually later.', + aiCharged: 'This generation consumed {count} AI point.', + }, + }, notFound: { title: 'This page got lost', description: @@ -723,7 +771,7 @@ const enUS = { sourceURL: 'Article URL', sourceTitle: 'Source title', language: 'Article language', - brandName: 'Brand / subject', + brandName: 'Current company', region: 'Region', keywords: 'Optimization keywords', preservePoints: 'Keep / emphasize', @@ -738,17 +786,17 @@ const enUS = { 'Selected groups are retrieved in the backend and passed to generation as reference context.', sourceURLPlaceholder: 'https://example.com/article', sourceTitlePlaceholder: 'Optional. Citation titles are prefilled automatically.', - brandNamePlaceholder: 'Required. Type the target subject, or choose from the brand library.', + brandNamePlaceholder: 'Select the current company in the top bar first.', regionPlaceholder: 'For example: Shanghai, East China, nationwide, North America', keywordsPlaceholder: - 'Type optimization keywords and press Enter. Brand-library options appear after choosing a subject.', + 'Type optimization keywords and press Enter. Search-term options appear after selecting the current company.', preservePointsPlaceholder: 'List facts, data, arguments, or examples that must be kept.', avoidPointsPlaceholder: 'List claims, competitors, compliance risks, or wording to avoid.', extraRequirementsPlaceholder: 'Add channel, structure, title style, or industry-specific instructions.', queued: 'Rewrite task submitted and queued.', sourceURLRequired: 'Enter the source article URL first.', - brandNameRequired: 'Enter the brand / subject first.', + brandNameRequired: 'Select the current company in the top bar first.', submitError: 'Failed to submit rewrite task.', backToList: 'Back to rewrites', }, @@ -821,7 +869,7 @@ const enUS = { drawerTitle: 'Template generation', steps: { basic: 'Basic info', - basicDesc: 'Language, template fields, and brand context', + basicDesc: 'Language, template fields, and company context', structure: 'Structure', structureDesc: 'Title and outline', generate: 'Generate', @@ -829,13 +877,13 @@ const enUS = { }, sections: { templateFields: 'Template fields', - brandContext: 'Brand context', + brandContext: 'Company context', language: 'Language', - brandInfo: 'Brand', - brandLibrary: 'Brand library', - brandName: 'Company name', + brandInfo: 'Company', + brandLibrary: 'Company library', + brandName: 'Current company', website: 'Official website', - brandSummary: 'AI brand summary', + brandSummary: 'AI company summary', keywords: 'Keywords', brandQuestions: 'Keyword library (search terms)', primaryQuestion: 'Optimization keyword', @@ -854,21 +902,21 @@ const enUS = { inputParams: 'Input params', }, hints: { - brandContext: 'Optional. Selecting a brand pulls in keywords and competitor context.', + brandContext: 'The current company pulls in keywords and competitor context.', brandFlow: - 'You can type a company name or choose one from the brand library. After you choose an optimization keyword, AI will prepare the summary, competitors, and title options.', + 'Articles use the current company selected in the top bar. After you choose an optimization keyword, AI will prepare the summary, competitors, and title options.', brandSummaryPlaceholder: - 'After analysis, AI will summarize the brand angle, positioning, and content opportunities here.', + 'After analysis, AI will summarize the company angle, positioning, and content opportunities here.', keywords: 'Type keywords directly or reuse brand-library keywords. AI results will be merged into this list.', brandQuestions: 'The optimization keyword controls the article spine; supplemental keywords broaden nearby search intent.', supplementalQuestions: 'Choose up to 3 so the article covers related intent without drifting.', - questionEmpty: 'No questions are available for this brand yet.', - selectBrandFirst: 'Choose a library brand from the company name dropdown first.', + questionEmpty: 'No questions are available for this company yet.', + selectBrandFirst: 'Select the current company in the top bar first.', competitors: - 'Competitors stay editable. You can add rows manually; saving to the competitor library is only available after choosing a library brand.', + 'Competitors stay editable. You can add rows manually; saving to the competitor library is available after selecting the current company.', competitorEmptyEditable: 'No competitors yet. Add one manually or let AI fill the list first.', templateFields: @@ -883,7 +931,7 @@ const enUS = { outlinePreview: 'The panel on the right updates from the current outline in real time.', outlineEmpty: 'Generate the outline first to edit the article structure here.', review: 'Check the final setup before creating the generation task.', - competitorEmpty: 'No competitors are available under this brand yet.', + competitorEmpty: 'No competitors are available under this company yet.', customTitle: 'Use this field if you want to override the title manually.', keyPoints: 'Add any key messages, angles, or style notes here.', async: @@ -904,7 +952,7 @@ const enUS = { review: { templateName: 'Template', articleTitle: 'Article title', - brandContext: 'Brand context', + brandContext: 'Company context', outline: 'Outline', currentTemplate: 'Current template', promptVisibility: 'Prompt visibility', @@ -922,8 +970,8 @@ const enUS = { actions: 'Actions', }, placeholders: { - brandLibrary: 'Choose an existing brand from the library', - brandName: 'Enter a company name, or choose from the brand library', + brandLibrary: 'Choose the current company', + brandName: 'Current company from the top bar', website: 'Official website, optional', keywords: 'Type keywords and press Enter', primaryQuestion: 'Choose one optimization keyword', @@ -975,18 +1023,18 @@ const enUS = { }, messages: { requiredField: 'Please fill in {field} first.', - missingBrand: 'Enter or choose a company name first.', + missingBrand: 'Select the current company in the top bar first.', selectBrandBeforeQuestion: - 'Choose a library brand from the company name dropdown before selecting a question.', + 'Select the current company in the top bar before continuing.', missingPrimaryQuestion: 'Choose one primary brand question.', missingTitle: 'Please choose or enter an article title.', missingOutline: 'Please select at least one outline section.', missingGeneratedOutline: 'Please generate and confirm the article outline first.', customOutlineTooLong: 'Custom sections cannot exceed {count} characters.', selectBrandBeforeCompetitors: - 'Choose a library brand from the company name dropdown before managing competitors.', + 'Select the current company in the top bar before managing competitors.', selectBrandBeforeFavorite: - 'Choose a library brand from the company name dropdown before saving competitors.', + 'Select the current company in the top bar before saving competitors.', missingCompetitorName: 'Enter a competitor name before saving it to the library.', savedCompetitor: 'Competitor saved to the brand library.', unsavedCompetitor: 'Competitor removed from the brand library.', diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index d25e8dd..e08c133 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -101,7 +101,7 @@ const zhCN = { email: "邮箱", loginIdentifier: "手机号 / 邮箱", password: "密码", - rememberAccount: "记住账号", + rememberAccount: "记住账号和密码", forgotPassword: "忘记密码", }, membershipBlocked: { @@ -134,6 +134,9 @@ const zhCN = { userFallback: "管理员", logout: "退出", planFallback: "免费版", + currentCompany: "当前公司", + currentCompanyPlaceholder: "选择公司", + createCompany: "创建公司", openNavigation: "打开导航", collapseNavigation: "收起导航", expandNavigation: "展开导航", @@ -165,6 +168,10 @@ const zhCN = { title: "欢迎回来", description: "登录省心推,开启品牌内容创作之旅", }, + brandOnboarding: { + title: "选择进入的品牌", + description: "首次进入前创建品牌,并初始化品牌关键词库。", + }, workspace: { title: "工作台", description: "模板入口、数据总览和最近生成都集中在这里。", @@ -238,6 +245,45 @@ const zhCN = { description: "你访问的链接可能已经失效或被移动。", }, }, + onboardingBrand: { + eyebrow: "Brand Onboarding", + title: "选择进入的品牌或公司", + subtitle: "你拥有多个品牌或公司的访问权限,请选择一个开始工作", + createBrand: "创建新品牌或公司", + createHint: "添加品牌或公司后会自动进入关键词初始化。", + emptyDescription: "新用户需要先创建品牌或公司,再进入工作台开始创作。", + noWebsite: "暂未填写官网", + form: { + title: "新建品牌或公司", + name: "品牌或公司名", + namePlaceholder: "例如:省心推 / 海翔家具", + website: "官网(选填)", + websitePlaceholder: "https://example.com", + description: "简介", + descriptionPlaceholder: "简单介绍公司的业务、产品、服务区域或核心卖点", + }, + ai: { + title: "正在生成品牌或公司问题", + stages: { + fetch: "正在获取官网信息和业务上下文…", + generate: "正在生成适合监测的候选搜索词…", + refine: "正在去重并筛选可保存的问题…", + }, + }, + questions: { + title: "选择品牌或公司问题", + subtitle: "请选择最多 {limit} 个问题保存到关键词库,后续可继续添加或调整。", + confirm: "保存并进入工作台", + skip: "跳过,直接进入", + empty: "暂时没有生成可用的问题,可以先进入工作台后手动添加。", + selectionLimit: "最多选择 {limit} 个问题", + }, + messages: { + nameRequired: "请输入品牌或公司名", + questionRequired: "请至少选择 1 个问题,或跳过后手动添加", + aiCharged: "本次生成已消耗 {count} 个 AI 点", + }, + }, notFound: { title: "页面走丢了", description: "我们没有找到这个地址对应的内容,可能链接拼写有误,或者页面已经下线。", @@ -691,7 +737,7 @@ const zhCN = { sourceURL: "文章 URL", sourceTitle: "来源标题", language: "文章语言", - brandName: "品牌/主体", + brandName: "当前公司", region: "地域", keywords: "优化关键词", preservePoints: "必须保留/强调", @@ -704,15 +750,15 @@ const zhCN = { knowledgeBaseHint: "选择后,后台会检索相关知识片段并作为生成参考,未选择则只基于来源文章和当前设置生成。", sourceURLPlaceholder: "https://example.com/article", sourceTitlePlaceholder: "可选,引用来源标题会自动带入", - brandNamePlaceholder: "必填,可输入目标达成词,或从品牌库选择", + brandNamePlaceholder: "请先在顶部选择当前公司", regionPlaceholder: "例如:上海、华东、全国、北美市场", - keywordsPlaceholder: "输入优化关键词后回车,可多选;选择品牌库主体后会显示搜索词候选", + keywordsPlaceholder: "输入优化关键词后回车,可多选;选择当前公司后会显示搜索词候选", preservePointsPlaceholder: "列出必须保留的事实、数据、观点或案例", avoidPointsPlaceholder: "列出不能出现的说法、竞品、合规禁区或表达方式", extraRequirementsPlaceholder: "补充行业、渠道、结构、标题风格等自由要求", queued: "仿写任务已提交,正在排队生成", sourceURLRequired: "请先填写来源文章 URL", - brandNameRequired: "请先填写品牌/主体", + brandNameRequired: "请先在顶部选择当前公司", submitError: "仿写任务提交失败", backToList: "返回仿写列表", }, @@ -785,7 +831,7 @@ const zhCN = { drawerTitle: "模版生成", steps: { basic: "基本信息", - basicDesc: "语言、模版字段与品牌上下文", + basicDesc: "语言、模版字段与公司上下文", structure: "文章结构", structureDesc: "标题方案与结构段落", generate: "生成文章", @@ -793,13 +839,13 @@ const zhCN = { }, sections: { templateFields: "模版字段", - brandContext: "品牌上下文", + brandContext: "公司上下文", language: "文章语言", - brandInfo: "品牌信息", - brandLibrary: "品牌库", - brandName: "公司名称", + brandInfo: "公司信息", + brandLibrary: "公司库", + brandName: "当前公司", website: "官网地址", - brandSummary: "AI 品牌摘要", + brandSummary: "AI 公司摘要", keywords: "关键词", brandQuestions: "关键词库(搜索词)", primaryQuestion: "优化关键词", @@ -819,15 +865,15 @@ const zhCN = { inputParams: "输入参数", }, hints: { - brandContext: "可选。选择后会自动带出关键词与竞品信息。", - brandFlow: "公司名称可直接输入,也可以从品牌库选择。选择优化关键词后,AI 会补充品牌摘要、竞品和标题建议。", - brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。", + brandContext: "当前公司会自动带出关键词与竞品信息。", + brandFlow: "使用顶部选择的当前公司生成文章。选择优化关键词后,AI 会补充公司摘要、竞品和标题建议。", + brandSummaryPlaceholder: "AI 分析后会在这里补充公司简介、差异点和适合切入的内容角度。", keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。", brandQuestions: "优化关键词决定文章主线,补充关键词只用于扩展相关搜索意图。", supplementalQuestions: "最多选择 3 个,用于覆盖相邻搜索问题,避免文章主题发散。", - questionEmpty: "当前品牌还没有可选问题", - selectBrandFirst: "请先从公司名称下拉选择品牌库对象", - competitors: "竞品支持手动编辑、追加;只有从品牌库选择品牌后,才可以收藏到品牌词库。AI 分析会尽量补齐候选竞品。", + questionEmpty: "当前公司还没有可选问题", + selectBrandFirst: "请先在顶部选择当前公司", + competitors: "竞品支持手动编辑、追加;选择当前公司后,可以收藏到公司词库。AI 分析会尽量补齐候选竞品。", competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。", templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。", titleSelection: "下一步前会由 AI 给出 5 个更合适的标题候选,你可以在这里挑选或改写。", @@ -839,7 +885,7 @@ const zhCN = { outlinePreview: "右侧会根据当前大纲实时预览最终文章框架。", outlineEmpty: "生成大纲后,这里会显示可编辑的文章结构。", review: "确认无误后提交,文章会进入生成队列。", - competitorEmpty: "当前品牌下暂无竞品数据", + competitorEmpty: "当前公司下暂无竞品数据", customTitle: "如需手动改写,可在这里输入最终标题", keyPoints: "补充希望文章强调的卖点、观点或风格要求", async: "后端会立即创建文章和任务记录,再异步完成正文生成。提交成功后,列表会自动刷新。", @@ -859,7 +905,7 @@ const zhCN = { review: { templateName: "模版名称", articleTitle: "文章标题", - brandContext: "品牌上下文", + brandContext: "公司上下文", outline: "结构段落", currentTemplate: "当前模版", promptVisibility: "Prompt 可见性", @@ -877,8 +923,8 @@ const zhCN = { actions: "操作", }, placeholders: { - brandLibrary: "从品牌库选择已有品牌", - brandName: "输入公司名称,或从品牌库选择", + brandLibrary: "选择当前公司", + brandName: "顶部选择的当前公司", website: "输入官网地址,选填", keywords: "输入关键词后回车,可多选", primaryQuestion: "选择一个优化关键词", @@ -927,16 +973,16 @@ const zhCN = { }, messages: { requiredField: "请先填写 {field}", - missingBrand: "请先输入或选择公司名称", - selectBrandBeforeQuestion: "请先从公司名称下拉选择品牌库对象后再选择优化关键词", + missingBrand: "请先在顶部选择当前公司", + selectBrandBeforeQuestion: "请先在顶部选择当前公司后再继续", missingPrimaryQuestion: "请选择一个优化关键词", missingTitle: "请先选择或输入文章标题", missingReviewIntroHook: "请先选择一个评测引言钩子", missingOutline: "请至少选择一个文章结构段落", missingGeneratedOutline: "请先生成并确认文章大纲", customOutlineTooLong: "自定义结构不能超过 {count} 个字", - selectBrandBeforeCompetitors: "请先从公司名称下拉选择品牌库对象后再管理竞品", - selectBrandBeforeFavorite: "请先从公司名称下拉选择品牌库对象后再收藏竞品", + selectBrandBeforeCompetitors: "请先在顶部选择当前公司后再管理竞品", + selectBrandBeforeFavorite: "请先在顶部选择当前公司后再收藏竞品", missingCompetitorName: "至少需要填写竞品名称后才能收藏", savedCompetitor: "竞品已收藏到品牌词库", unsavedCompetitor: "已取消收藏该竞品", diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue index 1c3a980..2d390b6 100644 --- a/apps/admin-web/src/layouts/AppShell.vue +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -13,25 +13,35 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, PictureOutlined, + PlusOutlined, + SearchOutlined, SendOutlined, ShopOutlined, SolutionOutlined, TagsOutlined, + TeamOutlined, ThunderboltOutlined, UserOutlined, WalletOutlined, } from '@ant-design/icons-vue' -import { useQuery } from '@tanstack/vue-query' -import { computed, onBeforeUnmount, onMounted, ref, type Component, watch } from 'vue' +import type { BrandLibrarySummary } from '@geo/shared-types' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, onBeforeUnmount, onMounted, reactive, ref, type Component, watch } from 'vue' +import { message } from 'ant-design-vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' -import { workspaceApi } from '@/lib/api' +import { brandsApi, workspaceApi } from '@/lib/api' +import { formatDateTime } from '@/lib/display' +import { formatError } from '@/lib/errors' import { useAuthStore } from '@/stores/auth' +import { useCompanyStore } from '@/stores/company' const route = useRoute() const router = useRouter() +const queryClient = useQueryClient() const authStore = useAuthStore() +const companyStore = useCompanyStore() const { locale, t } = useI18n() const MOBILE_BREAKPOINT = '(max-width: 991px)' @@ -53,6 +63,12 @@ const quotaQuery = useQuery({ enabled: computed(() => !isMembershipBlocked.value), }) +const brandLibrarySummaryQuery = useQuery({ + queryKey: ['brands', 'library-summary'], + queryFn: () => brandsApi.getLibrarySummary(), + enabled: computed(() => !isMembershipBlocked.value), +}) + const userDisplayName = computed(() => { return ( authStore.user?.name || @@ -66,6 +82,127 @@ const userSecondaryIdentifier = computed( ) const userInitial = computed(() => userDisplayName.value.slice(0, 1).toUpperCase() || 'A') const quotaSummary = computed(() => quotaQuery.data.value) +const popoverVisible = ref(false) +const searchQuery = ref('') +const currentFilter = ref<'created' | 'joined'>('created') +const brandModalOpen = ref(false) +const brandForm = reactive({ + name: '', + website: '', + description: '', +}) +const brandLibrarySummary = computed( + () => brandLibrarySummaryQuery.data.value ?? null, +) +const brandLimitReached = computed(() => { + if (!brandLibrarySummary.value) { + return false + } + return brandLibrarySummary.value.remaining_brands <= 0 +}) + +const currentBrandName = computed(() => { + return companyStore.currentBrand?.name || t('shell.currentCompanyPlaceholder') +}) + +const filteredBrands = computed(() => { + if (currentFilter.value === 'joined') { + return [] + } + + const query = searchQuery.value.trim().toLowerCase() + if (!query) { + return companyStore.brands + } + + return companyStore.brands.filter((brand) => brand.name.toLowerCase().includes(query)) +}) + +function getBrandInitials(name: string): string { + if (!name) return '' + return name.slice(0, 2) +} + +function getAvatarBg(name: string): string { + const gradients = [ + 'linear-gradient(135deg, #ff758c 0%, #ff7eb3 100%)', + 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', + 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', + 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', + 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', + ] + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + const index = Math.abs(hash) % gradients.length + return gradients[index] +} + +async function refreshBrandScopedData(): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['workspace'] }), + queryClient.invalidateQueries({ queryKey: ['articles'] }), + queryClient.invalidateQueries({ queryKey: ['tracking'] }), + queryClient.invalidateQueries({ queryKey: ['schedules'] }), + queryClient.invalidateQueries({ queryKey: ['instantTasks'] }), + ]) +} + +function selectBrand(brandId: number): void { + if (!Number.isFinite(brandId) || brandId <= 0) { + return + } + + companyStore.setCurrentBrand(brandId) + popoverVisible.value = false + searchQuery.value = '' + currentFilter.value = 'created' + void refreshBrandScopedData() +} + +function handleCreateBrand(): void { + if (brandLimitReached.value) { + message.warning( + t('brands.messages.brandLimitReached', { + limit: brandLibrarySummary.value?.max_brands ?? 0, + }), + ) + return + } + + popoverVisible.value = false + brandForm.name = '' + brandForm.website = '' + brandForm.description = '' + brandModalOpen.value = true +} + +const createBrandMutation = useMutation({ + mutationFn: () => + brandsApi.create({ + name: brandForm.name.trim(), + website: brandForm.website.trim() || null, + description: brandForm.description.trim() || null, + }), + onSuccess: async (brand) => { + message.success(t('brands.messages.createBrand')) + brandModalOpen.value = false + await queryClient.invalidateQueries({ queryKey: ['brands'] }) + await companyStore.refreshBrands() + companyStore.setCurrentBrand(brand.id) + await refreshBrandScopedData() + }, + onError: (error) => message.error(formatError(error)), +}) + +async function submitBrand(): Promise { + if (!brandForm.name.trim()) { + return + } + await createBrandMutation.mutateAsync() +} const membershipExpiryText = computed(() => { const value = authStore.membership?.end_at if (!value) { @@ -262,6 +399,7 @@ function syncMobileState(event?: MediaQueryListEvent | MediaQueryList): void { async function handleLogout(): Promise { await authStore.logout() + companyStore.reset() await router.replace('/login') } @@ -276,6 +414,10 @@ onMounted(() => { mobileMediaQuery = window.matchMedia(MOBILE_BREAKPOINT) syncMobileState(mobileMediaQuery) mobileMediaQuery.addEventListener('change', syncMobileState) + + if (!isMembershipBlocked.value) { + void companyStore.refreshBrands() + } }) onBeforeUnmount(() => { @@ -376,6 +518,120 @@ onBeforeUnmount(() => {
+
+ +
+ {{ currentBrandName }} + +
+ + +
+ + {{ t('shell.createCompany') }} + +
+
@@ -480,6 +736,26 @@ onBeforeUnmount(() => { + + + + + + + + + + + + + + @@ -666,10 +942,308 @@ onBeforeUnmount(() => { flex-shrink: 0; display: flex; align-items: center; - gap: 20px; + gap: 16px; margin-left: auto; } +.current-brand-control { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.current-brand-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; +} + +.current-brand-trigger:hover { + background: rgba(0, 0, 0, 0.04); +} + +.current-brand-trigger__name { + font-size: 15px; + font-weight: 600; + color: #1f2937; + line-height: 22px; +} + +.current-brand-trigger__arrow { + font-size: 10px; + color: #8c8c8c; + transition: transform 0.2s ease; +} + +.current-brand-trigger__arrow--open { + transform: rotate(180deg); +} + +.current-brand-control__create { + height: 28px; + padding: 0 8px; + font-size: 13px; + font-weight: 600; +} + +/* Custom Popover Container Style */ +:deep(.brand-popover-overlay .ant-popover-inner) { + padding: 0; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.09); + border: 1px solid #f0f0f0; +} + +:deep(.brand-popover-overlay .ant-popover-inner-content) { + padding: 0; +} + +.brand-popover { + display: flex; + width: 500px; + height: 360px; + background: #ffffff; +} + +/* Left Sidebar */ +.brand-popover__sidebar { + width: 165px; + background: #f8fafc; + border-right: 1px solid #f1f5f9; + padding: 16px 8px 12px; + display: flex; + flex-direction: column; +} + +.brand-popover__section-title { + font-size: 11px; + color: #94a3b8; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + padding-left: 8px; +} + +.brand-popover__menu { + display: flex; + flex-direction: column; + gap: 2px; +} + +.brand-popover__menu-item { + padding: 8px 10px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: all 0.2s ease; + color: #475569; + font-size: 13px; + font-weight: 500; +} + +.brand-popover__menu-item:hover { + background: #f1f5f9; + color: #0f172a; +} + +.brand-popover__menu-item--active { + background: #e0f2fe; + color: #0284c7; +} + +.brand-popover__menu-icon { + font-size: 14px; + display: flex; + align-items: center; +} + +.brand-popover__menu-text { + flex: 1; +} + +.brand-popover__badge { + background: #e2e8f0; + color: #64748b; + border-radius: 999px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + line-height: 12px; + min-width: 18px; + text-align: center; +} + +.brand-popover__badge--active { + background: #0284c7; + color: #ffffff; +} + +.brand-popover__badge--zero { + background: #f1f5f9; + color: #cbd5e1; +} + +.brand-popover__footer { + margin-top: auto; + padding: 12px 0 0; + border-top: 1px solid #e2e8f0; +} + +.brand-popover__action { + min-height: 40px; + padding: 9px 10px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: #0284c7; + font-weight: 600; + font-size: 13px; + transition: all 0.2s ease; +} + +.brand-popover__action:hover { + background: #f0f9ff; +} + +.brand-popover__action-icon { + font-size: 12px; +} + +/* Right Content Area */ +.brand-popover__content { + flex: 1; + padding: 14px; + display: flex; + flex-direction: column; + background: #ffffff; + min-width: 0; +} + +.brand-popover__search-wrapper { + margin-bottom: 10px; +} + +:deep(.brand-popover__search-input.ant-input-affine-wrapper) { + border-radius: 6px; + border-color: #cbd5e1; + padding: 4px 10px; +} + +:deep(.brand-popover__search-input.ant-input-affine-wrapper:focus), +:deep(.brand-popover__search-input.ant-input-affine-wrapper-focused) { + border-color: #0284c7; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.1); +} + +.brand-popover__list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 4px; +} + +.brand-popover__list::-webkit-scrollbar { + width: 4px; +} + +.brand-popover__list::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 2px; +} + +.brand-popover__list::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +.brand-popover__item { + padding: 8px 10px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid transparent; +} + +.brand-popover__item:hover { + background: #f8fafc; + transform: translateY(-0.5px); + border-color: #f1f5f9; +} + +.brand-popover__item--active { + background: #f0f9ff; + border-color: #bae6fd; +} + +.brand-popover__avatar { + width: 38px; + height: 38px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-weight: 700; + font-size: 13px; + flex-shrink: 0; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.brand-popover__info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.brand-popover__name { + font-size: 13.5px; + font-weight: 600; + color: #1e293b; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + line-height: 1.4; + transition: color 0.2s ease; +} + +.brand-popover__item:hover .brand-popover__name { + color: #0284c7; +} + +.brand-popover__item--active .brand-popover__name { + color: #0284c7; +} + +.brand-popover__date { + font-size: 11px; + color: #64748b; + margin-top: 1px; +} + +.brand-popover__empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + .quota-pill { display: flex; align-items: center; @@ -847,6 +1421,18 @@ onBeforeUnmount(() => { margin-left: 0; } + .current-brand-control { + max-width: 150px; + } + + .current-brand-trigger__name { + font-size: 14px; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .quota-pill { display: none; } @@ -882,5 +1468,9 @@ onBeforeUnmount(() => { .user-dropdown-trigger { padding: 4px; } + + .current-brand-control { + display: none; + } } diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index aa67547..8468ad0 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -148,6 +148,7 @@ import { readStoredSession, setStoredTokens, } from './session' +import { readStoredCurrentBrandId } from './current-brand' const rawBaseURL = import.meta.env.VITE_API_BASE_URL ?? '' const baseURL = normalizeApiBaseURL(rawBaseURL) @@ -312,6 +313,64 @@ export const apiClient = createApiClient({ }, }) +const currentBrandScopedPathPatterns = [ + /^\/api\/tenant\/workspace\/(?:overview|recent-articles)(?:\/|$)/, + /^\/api\/tenant\/articles(?:\/|$)/, + /^\/api\/tenant\/templates\/[^/]+\/(?:drafts|generate)(?:\/|$)/, + /^\/api\/tenant\/kol\/subscription-prompts\/[^/]+\/generate(?:\/|$)/, + /^\/api\/tenant\/schedules(?:\/|$)/, + /^\/api\/tenant\/instant-tasks(?:\/|$)/, + /^\/api\/tenant\/publish-jobs(?:\/|$)/, + /^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/, +] + +function shouldAttachCurrentBrandHeader(url: unknown): boolean { + if (typeof url !== 'string') { + return false + } + const pathname = normalizeRequestPath(url) + return currentBrandScopedPathPatterns.some((pattern) => pattern.test(pathname)) +} + +function normalizeRequestPath(url: string): string { + if (/^https?:\/\//i.test(url)) { + try { + return new URL(url).pathname + } catch { + return url + } + } + return url.split('?')[0] || url +} + +function buildCurrentBrandHeaders(path: string): Headers | undefined { + if (!shouldAttachCurrentBrandHeader(path)) { + return undefined + } + const brandId = readStoredCurrentBrandId() + if (!brandId) { + return undefined + } + const headers = new Headers() + headers.set('X-Brand-ID', String(brandId)) + return headers +} + +apiClient.raw.interceptors.request.use((config) => { + if (!shouldAttachCurrentBrandHeader(config.url)) { + return config + } + + const brandId = readStoredCurrentBrandId() + if (!brandId) { + return config + } + + config.headers = config.headers ?? {} + ;(config.headers as Record)['X-Brand-ID'] = String(brandId) + return config +}) + const membershipBlockedErrors = new Set([ 'user_disabled', 'trial_plan_expired', @@ -910,6 +969,8 @@ async function subscribeSSE( const requestHeaders = new Headers(init.headers) requestHeaders.set('Accept', 'text/event-stream') requestHeaders.set('Authorization', `Bearer ${await getFreshStoredAccessToken()}`) + const currentBrandHeaders = buildCurrentBrandHeaders(path) + currentBrandHeaders?.forEach((value, key) => requestHeaders.set(key, value)) let response = await fetch(`${baseURL}${path}`, { ...init, diff --git a/apps/admin-web/src/lib/current-brand.ts b/apps/admin-web/src/lib/current-brand.ts new file mode 100644 index 0000000..51831b5 --- /dev/null +++ b/apps/admin-web/src/lib/current-brand.ts @@ -0,0 +1,25 @@ +const CURRENT_BRAND_STORAGE_KEY = 'geo-rankly.current-brand-id' + +export function readStoredCurrentBrandId(): number | null { + if (typeof window === 'undefined') { + return null + } + + const raw = window.localStorage.getItem(CURRENT_BRAND_STORAGE_KEY) + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : null +} + +export function writeStoredCurrentBrandId(brandId: number): void { + if (typeof window === 'undefined') { + return + } + window.localStorage.setItem(CURRENT_BRAND_STORAGE_KEY, String(brandId)) +} + +export function clearStoredCurrentBrandId(): void { + if (typeof window === 'undefined') { + return + } + window.localStorage.removeItem(CURRENT_BRAND_STORAGE_KEY) +} diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts index 522623d..eea283d 100644 --- a/apps/admin-web/src/main.ts +++ b/apps/admin-web/src/main.ts @@ -108,7 +108,6 @@ const IconFont = createFromIconfontCN({ }) app.component('IconFont', IconFont) -app.component('ATextarea', Input.TextArea) ;[ Alert, AntApp, diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index b6649c7..df5e987 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -2,8 +2,31 @@ import { createRouter, createWebHistory } from 'vue-router' import { isChunkLoadError, reloadForStaleChunk } from '@/lib/chunk-reload' import { useAuthStore } from '@/stores/auth' +import { useCompanyStore } from '@/stores/company' import { pinia } from '@/stores/pinia' +let brandRefreshPromise: Promise | null = null + +async function ensureBrandsInitialized(): Promise { + const companyStore = useCompanyStore(pinia) + if (companyStore.initialized) { + return true + } + + if (!brandRefreshPromise) { + brandRefreshPromise = companyStore.refreshBrands().finally(() => { + brandRefreshPromise = null + }) + } + + try { + await brandRefreshPromise + return true + } catch { + return false + } +} + const router = createRouter({ history: createWebHistory(), routes: [ @@ -24,6 +47,17 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/onboarding/brand', + name: 'brand-onboarding', + component: () => import('@/views/BrandOnboardingView.vue'), + meta: { + requiresAuth: true, + onboarding: true, + titleKey: 'route.brandOnboarding.title', + descriptionKey: 'route.brandOnboarding.description', + }, + }, { path: '/', component: () => import('@/layouts/AppShell.vue'), @@ -279,6 +313,7 @@ const router = createRouter({ router.beforeEach(async (to) => { const authStore = useAuthStore(pinia) + const companyStore = useCompanyStore(pinia) if (!authStore.initialized) { await authStore.bootstrap() @@ -292,11 +327,23 @@ router.beforeEach(async (to) => { } } - if (to.meta.requiresKol && !authStore.isActiveKol) { + if (to.name === 'login' && authStore.isAuthenticated) { return { name: 'workspace' } } - if (to.name === 'login' && authStore.isAuthenticated) { + if (requiresAuth && authStore.isAuthenticated && !authStore.isMembershipBlocked) { + const brandsInitialized = await ensureBrandsInitialized() + + if (brandsInitialized && !companyStore.hasBrands && !to.meta.onboarding) { + return { name: 'brand-onboarding' } + } + + if (brandsInitialized && companyStore.hasBrands && to.meta.onboarding) { + return { name: 'workspace' } + } + } + + if (to.meta.requiresKol && !authStore.isActiveKol) { return { name: 'workspace' } } diff --git a/apps/admin-web/src/router/meta.d.ts b/apps/admin-web/src/router/meta.d.ts index e067875..a1c16cc 100644 --- a/apps/admin-web/src/router/meta.d.ts +++ b/apps/admin-web/src/router/meta.d.ts @@ -8,6 +8,7 @@ declare module 'vue-router' { descriptionKey?: string requiresAuth?: boolean requiresKol?: boolean + onboarding?: boolean navKey?: string | null } } diff --git a/apps/admin-web/src/stores/company.ts b/apps/admin-web/src/stores/company.ts new file mode 100644 index 0000000..926c24e --- /dev/null +++ b/apps/admin-web/src/stores/company.ts @@ -0,0 +1,92 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { Brand } from '@geo/shared-types' + +import { brandsApi } from '@/lib/api' +import { + clearStoredCurrentBrandId, + readStoredCurrentBrandId, + writeStoredCurrentBrandId, +} from '@/lib/current-brand' + +export const useCompanyStore = defineStore('company', () => { + const brands = ref([]) + const currentBrandId = ref(readStoredCurrentBrandId()) + const loading = ref(false) + const initialized = ref(false) + + const currentBrand = computed( + () => brands.value.find((brand) => brand.id === currentBrandId.value) ?? null, + ) + const hasBrands = computed(() => brands.value.length > 0) + + function setCurrentBrand(brandId: number | null): void { + if (!brandId) { + currentBrandId.value = null + clearStoredCurrentBrandId() + return + } + + currentBrandId.value = brandId + writeStoredCurrentBrandId(brandId) + } + + function normalizeBrandList(value: Brand[] | null | undefined): Brand[] { + return Array.isArray(value) ? value : [] + } + + function syncCurrentBrand(value: Brand[] | null | undefined): void { + const nextBrands = normalizeBrandList(value) + const storedId = readStoredCurrentBrandId() + const preferredId = currentBrandId.value ?? storedId + const matched = preferredId + ? nextBrands.find((brand) => brand.id === preferredId) + : undefined + + if (matched) { + setCurrentBrand(matched.id) + return + } + + if (nextBrands.length > 0) { + setCurrentBrand(nextBrands[0].id) + return + } + + setCurrentBrand(null) + } + + async function refreshBrands(): Promise { + loading.value = true + try { + const nextBrands = normalizeBrandList(await brandsApi.list()) + brands.value = nextBrands + syncCurrentBrand(nextBrands) + initialized.value = true + return nextBrands + } finally { + loading.value = false + } + } + + function reset(): void { + brands.value = [] + currentBrandId.value = null + initialized.value = false + loading.value = false + clearStoredCurrentBrandId() + } + + return { + brands, + currentBrandId, + currentBrand, + hasBrands, + loading, + initialized, + refreshBrands, + setCurrentBrand, + reset, + } +}) diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index 5c5db72..236b362 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -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 & { 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(null) const editorCanvasRef = ref(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), }) diff --git a/apps/admin-web/src/views/BrandOnboardingView.vue b/apps/admin-web/src/views/BrandOnboardingView.vue new file mode 100644 index 0000000..e8f4ba0 --- /dev/null +++ b/apps/admin-web/src/views/BrandOnboardingView.vue @@ -0,0 +1,1107 @@ + + + + + diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue index db54925..ea31a9b 100644 --- a/apps/admin-web/src/views/BrandsView.vue +++ b/apps/admin-web/src/views/BrandsView.vue @@ -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(null) +const selectedBrandId = computed({ + get: () => companyStore.currentBrandId, + set: (brandId) => companyStore.setCurrentBrand(brandId), +}) const brandModalOpen = ref(false) const editingBrandId = ref(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 { queryClient.invalidateQueries({ queryKey: ['brands'] }), queryClient.invalidateQueries({ queryKey: ['workspace'] }), ]) + await companyStore.refreshBrands() } @@ -440,7 +455,7 @@ async function invalidateBrandQueries(): Promise { :key="brand.id" class="brand-card" :class="{ 'brand-card--active': brand.id === selectedBrandId }" - @click="selectedBrandId = brand.id" + @click="selectBrand(brand.id)" >
diff --git a/apps/admin-web/src/views/FreeCreateView.vue b/apps/admin-web/src/views/FreeCreateView.vue index 8e7be62..9b099ea 100644 --- a/apps/admin-web/src/views/FreeCreateView.vue +++ b/apps/admin-web/src/views/FreeCreateView.vue @@ -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(null) @@ -75,7 +77,8 @@ const articleParams = computed(() => { }) 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 { } 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 {

{{ t('route.freeCreate.description') }}

- + {{ t('freeCreate.actions.create') }} diff --git a/apps/admin-web/src/views/ImitationGenerateView.vue b/apps/admin-web/src/views/ImitationGenerateView.vue index ec7c090..352363a 100644 --- a/apps/admin-web/src/views/ImitationGenerateView.vue +++ b/apps/admin-web/src/views/ImitationGenerateView.vue @@ -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 {
- +
+ {{ currentBrandName || t('shell.currentCompanyPlaceholder') }} + {{ selectedBrand.website }} +
@@ -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; diff --git a/apps/admin-web/src/views/ImitationView.vue b/apps/admin-web/src/views/ImitationView.vue index ebac4f4..6a605bf 100644 --- a/apps/admin-web/src/views/ImitationView.vue +++ b/apps/admin-web/src/views/ImitationView.vue @@ -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(null) @@ -80,7 +82,8 @@ const articleParams = computed(() => { }) 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 {

{{ t('route.imitation.description') }}

- + {{ t('imitation.actions.create') }} diff --git a/apps/admin-web/src/views/KolGenerateView.vue b/apps/admin-web/src/views/KolGenerateView.vue index de87545..030d71a 100644 --- a/apps/admin-web/src/views/KolGenerateView.vue +++ b/apps/admin-web/src/views/KolGenerateView.vue @@ -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) => { diff --git a/apps/admin-web/src/views/LoginView.vue b/apps/admin-web/src/views/LoginView.vue index 6cc4224..a980ad7 100644 --- a/apps/admin-web/src/views/LoginView.vue +++ b/apps/admin-web/src/views/LoginView.vue @@ -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 { try { await authStore.login(formState) + companyStore.reset() persistRememberedCredentials() const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/workspace' await router.replace(redirect) diff --git a/apps/admin-web/src/views/PublishManagementView.vue b/apps/admin-web/src/views/PublishManagementView.vue index a6d8c9a..e32b9a2 100644 --- a/apps/admin-web/src/views/PublishManagementView.vue +++ b/apps/admin-web/src/views/PublishManagementView.vue @@ -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, diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index 6160dbe..4d4c90e 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -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(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): 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): 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): 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 { 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 { - +
+ {{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }} + {{ selectedBrand.website }} +
@@ -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; diff --git a/apps/admin-web/src/views/TemplatesView.vue b/apps/admin-web/src/views/TemplatesView.vue index 3aeee4b..5d89ae5 100644 --- a/apps/admin-web/src/views/TemplatesView.vue +++ b/apps/admin-web/src/views/TemplatesView.vue @@ -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(() => { }) 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 {

{{ t('route.templates.description') }}

- + {{ t('templates.actions.chooseTemplate') }} diff --git a/apps/admin-web/src/views/TrackingQuestionDetailView.vue b/apps/admin-web/src/views/TrackingQuestionDetailView.vue index 781afee..8ead025 100644 --- a/apps/admin-web/src/views/TrackingQuestionDetailView.vue +++ b/apps/admin-web/src/views/TrackingQuestionDetailView.vue @@ -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)) diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue index b1d90c6..9b053b4 100644 --- a/apps/admin-web/src/views/TrackingView.vue +++ b/apps/admin-web/src/views/TrackingView.vue @@ -1,7 +1,6 @@