feat: add brand library management features

- Introduced BrandLibrarySummary type to encapsulate brand library limits and usage.
- Updated Brand interface to include keyword_count and question_count fields.
- Implemented brand library limits in the backend, including max brands, keywords, and questions per keyword.
- Added API endpoint to retrieve brand library summary.
- Enhanced BrandsView.vue to display brand library usage and limits.
- Implemented computed properties to manage brand, keyword, and question limits in the UI.
- Updated mutation handlers to invalidate relevant queries upon creating/updating brands, keywords, and questions.
- Added visual indicators for brand, keyword, and question limits in the UI.
- Enhanced error handling for exceeding brand and keyword limits during creation.
This commit is contained in:
2026-04-16 21:01:40 +08:00
parent 27389164b0
commit 41f3060e00
15 changed files with 731 additions and 44 deletions
+20
View File
@@ -811,6 +811,23 @@ const enUS = {
keywords: "Keywords",
competitors: "Competitors",
},
quota: {
plan: "Current plan",
planHint: "Brand library limits are enforced from the active plan.",
brands: "Brand companies",
brandsHint: "Free defaults to 1 and paid defaults to 2.",
keywords: "Total keywords",
keywordsHint: "The total number of keywords this account can bind.",
questions: "Questions per keyword",
questionsHint: "The maximum number of questions allowed under one keyword.",
},
meta: {
keywordCount: "{count} keywords",
questionCount: "{count} questions",
brandKeywordCount: "{count} keywords under this brand",
globalKeywordUsage: "Total keywords used {used} / {total}",
keywordQuestionUsage: "Questions {used} / {total}",
},
sections: {
keywords: "Keywords",
questions: "Question set",
@@ -855,6 +872,9 @@ const enUS = {
deleteCompetitor: "Competitor deleted.",
chooseBrand: "Please choose a brand first.",
chooseKeyword: "Please choose a keyword first.",
brandLimitReached: "Your current plan allows up to {limit} brand companies.",
keywordLimitReached: "This account allows up to {limit} keywords.",
questionLimitReached: "This keyword allows up to {limit} questions.",
},
},
custom: {
+20
View File
@@ -818,6 +818,23 @@ const zhCN = {
keywords: "关键词",
competitors: "竞品库",
},
quota: {
plan: "当前套餐",
planHint: "品牌词库额度会按套餐实时生效。",
brands: "品牌公司",
brandsHint: "免费版默认 1 个,付费版默认 2 个。",
keywords: "关键词总量",
keywordsHint: "当前用户可绑定的关键词总数。",
questions: "每个关键词问题数",
questionsHint: "单个关键词下最多可维护的问题数。",
},
meta: {
keywordCount: "{count} 个关键词",
questionCount: "{count} 个问题",
brandKeywordCount: "当前品牌已绑定 {count} 个关键词",
globalKeywordUsage: "总关键词已用 {used} / {total}",
keywordQuestionUsage: "问题 {used} / {total}",
},
sections: {
keywords: "关键词",
questions: "问题集",
@@ -862,6 +879,9 @@ const zhCN = {
deleteCompetitor: "竞品已删除",
chooseBrand: "请先选择品牌",
chooseKeyword: "请先选择关键词",
brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司",
keywordLimitReached: "当前账号最多可绑定 {limit} 个关键词",
questionLimitReached: "当前关键词下最多可维护 {limit} 个问题",
},
},
custom: {
+4
View File
@@ -9,6 +9,7 @@ import type {
ArticleVersion,
AuthTokens,
Brand,
BrandLibrarySummary,
BrandRequest,
Competitor,
CompetitorRequest,
@@ -540,6 +541,9 @@ export const brandsApi = {
list() {
return apiClient.get<Brand[]>("/api/tenant/brands");
},
getLibrarySummary() {
return apiClient.get<BrandLibrarySummary>("/api/tenant/brands/library-summary");
},
create(payload: BrandRequest) {
return apiClient.post<Brand, BrandRequest>("/api/tenant/brands", payload);
},
+7
View File
@@ -39,6 +39,13 @@ const errorMessageMap: Record<string, string> = {
object_storage_unavailable: "对象存储未配置或当前不可用",
invalid_payload: "请求参数不合法",
update_failed: "保存文章失败",
brand_exists: "品牌名称已存在",
brand_limit_reached: "品牌公司数量已达当前套餐上限",
keyword_exists: "关键词已存在",
keyword_limit_reached: "关键词数量已达当前套餐上限",
question_limit_reached: "当前关键词下的问题数量已达上限",
brand_not_found: "品牌不存在或已删除",
keyword_not_found: "关键词不存在或已删除",
publish_cover_required: "已选择百家号,请先上传封面图",
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
+246 -9
View File
@@ -6,7 +6,7 @@ import {
} from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { Brand, Competitor, Keyword, Question } from "@geo/shared-types";
import type { Brand, BrandLibrarySummary, Competitor, Keyword, Question } from "@geo/shared-types";
import type { TableColumnsType } from "ant-design-vue";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
@@ -58,6 +58,11 @@ const brandListQuery = useQuery({
queryFn: () => brandsApi.list(),
});
const brandLibrarySummaryQuery = useQuery({
queryKey: ["brands", "library-summary"],
queryFn: () => brandsApi.getLibrarySummary(),
});
const keywordsQuery = useQuery({
queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]),
enabled: computed(() => Boolean(selectedBrandId.value)),
@@ -82,6 +87,49 @@ const competitorsQuery = useQuery({
queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number),
});
const brandLibrarySummary = computed<BrandLibrarySummary | null>(() => brandLibrarySummaryQuery.data.value ?? null);
const selectedBrand = computed(() =>
brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
);
const questionCountByKeyword = computed(() => {
const counts = new Map<number, number>();
for (const item of allQuestionsQuery.data.value ?? []) {
counts.set(item.keyword_id, (counts.get(item.keyword_id) ?? 0) + 1);
}
return counts;
});
const selectedBrandKeywordCount = computed(() => selectedBrand.value?.keyword_count ?? 0);
const selectedKeywordQuestionCount = computed(() => {
if (!selectedKeywordId.value) {
return 0;
}
return questionCountByKeyword.value.get(selectedKeywordId.value) ?? 0;
});
const brandLimitReached = computed(() => {
if (!brandLibrarySummary.value) {
return false;
}
return brandLibrarySummary.value.remaining_brands <= 0;
});
const keywordLimitReached = computed(() => {
if (!brandLibrarySummary.value) {
return false;
}
return brandLibrarySummary.value.remaining_keywords <= 0;
});
const questionLimitReached = computed(() => {
if (!brandLibrarySummary.value || !selectedKeywordId.value) {
return false;
}
return selectedKeywordQuestionCount.value >= brandLibrarySummary.value.max_questions_per_keyword;
});
const brandMutations = {
create: useMutation({
mutationFn: () =>
@@ -141,7 +189,10 @@ const keywordMutations = {
onSuccess: async () => {
message.success(t("brands.messages.createKeyword"));
keywordModalOpen.value = false;
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["brands"] }),
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
]);
},
onError: (error) => message.error(formatError(error)),
}),
@@ -153,7 +204,10 @@ const keywordMutations = {
onSuccess: async () => {
message.success(t("brands.messages.updateKeyword"));
keywordModalOpen.value = false;
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["brands"] }),
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
]);
},
onError: (error) => message.error(formatError(error)),
}),
@@ -166,6 +220,7 @@ const keywordMutations = {
selectedKeywordId.value = null;
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["brands"] }),
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
]);
@@ -184,7 +239,10 @@ const questionMutations = {
onSuccess: async () => {
message.success(t("brands.messages.createQuestion"));
questionModalOpen.value = false;
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["brands"] }),
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
]);
},
onError: (error) => message.error(formatError(error)),
}),
@@ -205,7 +263,10 @@ const questionMutations = {
brandsApi.removeQuestion(selectedBrandId.value as number, questionId),
onSuccess: async () => {
message.success(t("brands.messages.deleteQuestion"));
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["brands"] }),
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
]);
},
onError: (error) => message.error(formatError(error)),
}),
@@ -319,6 +380,14 @@ function stringifyProductLines(value: unknown): string {
}
function openBrandModal(brand?: Brand): void {
if (!brand && brandLimitReached.value) {
message.warning(
t("brands.messages.brandLimitReached", {
limit: brandLibrarySummary.value?.max_brands ?? 0,
}),
);
return;
}
editingBrandId.value = brand?.id ?? null;
brandForm.name = brand?.name ?? "";
brandForm.website = brand?.website ?? "";
@@ -331,6 +400,14 @@ function openKeywordModal(keyword?: Keyword): void {
message.warning(t("brands.messages.chooseBrand"));
return;
}
if (!keyword && keywordLimitReached.value) {
message.warning(
t("brands.messages.keywordLimitReached", {
limit: brandLibrarySummary.value?.max_keywords ?? 0,
}),
);
return;
}
editingKeywordId.value = keyword?.id ?? null;
keywordForm.name = keyword?.name ?? "";
keywordModalOpen.value = true;
@@ -341,6 +418,14 @@ function openQuestionModal(question?: Question): void {
message.warning(t("brands.messages.chooseBrand"));
return;
}
if (!question && questionLimitReached.value) {
message.warning(
t("brands.messages.questionLimitReached", {
limit: brandLibrarySummary.value?.max_questions_per_keyword ?? 0,
}),
);
return;
}
editingQuestionId.value = question?.id ?? null;
questionForm.keyword_id = question?.keyword_id ?? selectedKeywordId.value ?? undefined;
questionForm.question_text = question?.question_text ?? "";
@@ -419,12 +504,39 @@ async function submitCompetitor(): Promise<void> {
<p>{{ t('brands.description') }}</p>
</div>
<div class="brands-view__header-actions">
<a-button type="primary" @click="openBrandModal()">
<a-button type="primary" :disabled="brandLimitReached" @click="openBrandModal()">
<template #icon><PlusOutlined /></template>
{{ t("brands.newBrand") }}
</a-button>
</div>
</div>
<div class="brands-view__quota-strip">
<div class="brands-view__quota-card brands-view__quota-card--plan">
<span class="brands-view__quota-label">{{ t("brands.quota.plan") }}</span>
<strong>{{ brandLibrarySummary?.plan_name || t("shell.planFallback") }}</strong>
<p>{{ t("brands.quota.planHint") }}</p>
</div>
<div class="brands-view__quota-card">
<span class="brands-view__quota-label">{{ t("brands.quota.brands") }}</span>
<strong>
{{ brandLibrarySummary?.used_brands ?? 0 }} / {{ brandLibrarySummary?.max_brands ?? "--" }}
</strong>
<p>{{ t("brands.quota.brandsHint") }}</p>
</div>
<div class="brands-view__quota-card">
<span class="brands-view__quota-label">{{ t("brands.quota.keywords") }}</span>
<strong>
{{ brandLibrarySummary?.used_keywords ?? 0 }} / {{ brandLibrarySummary?.max_keywords ?? "--" }}
</strong>
<p>{{ t("brands.quota.keywordsHint") }}</p>
</div>
<div class="brands-view__quota-card">
<span class="brands-view__quota-label">{{ t("brands.quota.questions") }}</span>
<strong>{{ brandLibrarySummary?.max_questions_per_keyword ?? "--" }}</strong>
<p>{{ t("brands.quota.questionsHint") }}</p>
</div>
</div>
</section>
<section class="brands-view__brand-rail">
@@ -476,6 +588,11 @@ async function submitCompetitor(): Promise<void> {
</a-popconfirm>
</div>
</div>
<div class="brands-view__brand-meta">
<span>{{ t("brands.meta.keywordCount", { count: brand.keyword_count }) }}</span>
<span>{{ t("brands.meta.questionCount", { count: brand.question_count }) }}</span>
</div>
</article>
</div>
<a-empty v-else :description="t('brands.empty.brands')" />
@@ -489,8 +606,21 @@ async function submitCompetitor(): Promise<void> {
<div class="brands-view__section-head brands-view__section-head--compact">
<div>
<h3>{{ t("brands.sections.keywords") }}</h3>
<p class="brands-view__section-hint">
{{ t("brands.meta.brandKeywordCount", { count: selectedBrandKeywordCount }) }}
<span class="brands-view__dot">·</span>
{{ t("brands.meta.globalKeywordUsage", {
used: brandLibrarySummary?.used_keywords ?? 0,
total: brandLibrarySummary?.max_keywords ?? 0,
}) }}
</p>
</div>
<a-button type="primary" size="small" @click="openKeywordModal()">
<a-button
type="primary"
size="small"
:disabled="keywordLimitReached"
@click="openKeywordModal()"
>
{{ t("brands.actions.newKeyword") }}
</a-button>
</div>
@@ -504,7 +634,15 @@ async function submitCompetitor(): Promise<void> {
:class="{ 'brands-view__keyword-item--active': keyword.id === selectedKeywordId }"
@click="selectedKeywordId = keyword.id"
>
<span>{{ keyword.name }}</span>
<div class="brands-view__keyword-copy">
<span>{{ keyword.name }}</span>
<small>
{{ t("brands.meta.keywordQuestionUsage", {
used: questionCountByKeyword.get(keyword.id) ?? 0,
total: brandLibrarySummary?.max_questions_per_keyword ?? 0,
}) }}
</small>
</div>
<div class="brands-view__keyword-actions">
<a-tooltip :title="t('common.edit')">
<a-button
@@ -541,8 +679,19 @@ async function submitCompetitor(): Promise<void> {
<div>
<p class="eyebrow">{{ t("brands.sections.questions") }}</p>
<h3>{{ selectedKeyword?.name || t("brands.sections.questions") }}</h3>
<p class="brands-view__section-hint">
{{ t("brands.meta.keywordQuestionUsage", {
used: selectedKeywordQuestionCount,
total: brandLibrarySummary?.max_questions_per_keyword ?? 0,
}) }}
</p>
</div>
<a-button type="primary" size="small" @click="openQuestionModal()">
<a-button
type="primary"
size="small"
:disabled="!selectedKeywordId || questionLimitReached"
@click="openQuestionModal()"
>
{{ t("brands.actions.newQuestion") }}
</a-button>
</div>
@@ -775,6 +924,49 @@ async function submitCompetitor(): Promise<void> {
padding: 24px;
}
.brands-view__quota-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 0 24px 24px;
border-top: 1px solid #eef3f8;
}
.brands-view__quota-card {
padding: 16px 18px;
border: 1px solid #e6edf5;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
}
.brands-view__quota-card--plan {
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
border-color: #cfe0ff;
}
.brands-view__quota-label {
display: block;
margin-bottom: 8px;
font-size: 12px;
letter-spacing: 0.04em;
color: #6b7280;
text-transform: uppercase;
}
.brands-view__quota-card strong {
display: block;
font-size: 22px;
line-height: 1.2;
color: #111827;
}
.brands-view__quota-card p {
margin: 8px 0 0;
color: #6b7280;
font-size: 13px;
line-height: 1.5;
}
.brands-view__header-title h2 {
margin: 0;
font-size: 20px;
@@ -830,6 +1022,17 @@ async function submitCompetitor(): Promise<void> {
height: 16px;
}
.brands-view__section-hint {
margin: 8px 0 0;
color: #6b7280;
font-size: 13px;
}
.brands-view__dot {
margin: 0 8px;
color: #c0cad8;
}
.brands-view__brand-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -893,6 +1096,21 @@ async function submitCompetitor(): Promise<void> {
line-height: 1.6;
}
.brands-view__brand-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.brands-view__brand-meta span {
padding: 6px 10px;
border-radius: 999px;
background: #edf4ff;
color: #2458d3;
font-size: 12px;
font-weight: 600;
}
.brands-view__brand-card-actions,
.brands-view__keyword-actions,
.brands-view__inline-actions {
@@ -927,6 +1145,23 @@ async function submitCompetitor(): Promise<void> {
transition: all 0.2s;
}
.brands-view__keyword-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.brands-view__keyword-copy span {
font-weight: 600;
color: #111827;
}
.brands-view__keyword-copy small {
color: #6b7280;
font-size: 12px;
}
.brands-view__keyword-item:hover {
border-color: #bfd7ff;
background: #f0f7ff;
@@ -992,6 +1227,7 @@ async function submitCompetitor(): Promise<void> {
}
@media (max-width: 1080px) {
.brands-view__quota-strip,
.brands-view__brand-grid,
.brands-view__keyword-layout,
.brands-view__competitor-grid {
@@ -1000,6 +1236,7 @@ async function submitCompetitor(): Promise<void> {
}
@media (max-width: 720px) {
.brands-view__header,
.brands-view__section-head {
flex-direction: column;
align-items: stretch;