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
+594 -4
View File
@@ -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<BrandLibrarySummary | null>(
() => 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<void> {
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<void> {
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<void> {
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(() => {
</nav>
<div class="admin-header-actions">
<div v-if="!isMembershipBlocked" class="current-brand-control">
<a-popover
v-if="companyStore.hasBrands"
v-model:open="popoverVisible"
trigger="click"
placement="bottomLeft"
overlay-class-name="brand-popover-overlay"
:arrow="false"
>
<div class="current-brand-trigger">
<span class="current-brand-trigger__name">{{ currentBrandName }}</span>
<DownOutlined class="current-brand-trigger__arrow" :class="{ 'current-brand-trigger__arrow--open': popoverVisible }" />
</div>
<template #content>
<div class="brand-popover">
<!-- Left Sidebar -->
<div class="brand-popover__sidebar">
<div class="brand-popover__section-title">品牌</div>
<div class="brand-popover__menu">
<div
class="brand-popover__menu-item"
:class="{ 'brand-popover__menu-item--active': currentFilter === 'created' }"
@click="currentFilter = 'created'"
>
<span class="brand-popover__menu-icon">
<TagsOutlined />
</span>
<span class="brand-popover__menu-text">我创建的</span>
<span class="brand-popover__badge" :class="{ 'brand-popover__badge--active': currentFilter === 'created' }">
{{ companyStore.brands.length }}
</span>
</div>
<div
class="brand-popover__menu-item"
:class="{ 'brand-popover__menu-item--active': currentFilter === 'joined' }"
@click="currentFilter = 'joined'"
>
<span class="brand-popover__menu-icon">
<TeamOutlined />
</span>
<span class="brand-popover__menu-text">我加入的</span>
<span class="brand-popover__badge brand-popover__badge--zero">0</span>
</div>
</div>
<div class="brand-popover__footer">
<div
class="brand-popover__action"
@pointerdown.prevent.stop="handleCreateBrand"
>
<PlusOutlined class="brand-popover__action-icon" />
<span>创建品牌</span>
</div>
</div>
</div>
<!-- Right Content Area -->
<div class="brand-popover__content">
<div class="brand-popover__search-wrapper">
<a-input
v-model:value="searchQuery"
placeholder="搜索品牌"
class="brand-popover__search-input"
allow-clear
>
<template #suffix>
<SearchOutlined style="color: #bfbfbf" />
</template>
</a-input>
</div>
<div class="brand-popover__list" v-if="filteredBrands.length > 0">
<div
v-for="brand in filteredBrands"
:key="brand.id"
class="brand-popover__item"
:class="{ 'brand-popover__item--active': companyStore.currentBrandId === brand.id }"
@pointerdown.prevent.stop="selectBrand(brand.id)"
>
<div
class="brand-popover__avatar"
:style="{ background: getAvatarBg(brand.name) }"
>
{{ getBrandInitials(brand.name) }}
</div>
<div class="brand-popover__info">
<div class="brand-popover__name">{{ brand.name }}</div>
<div class="brand-popover__date">
创建于 {{ formatDateTime(brand.created_at, 'YYYY-MM-DD HH:mm:ss') }}
</div>
</div>
</div>
</div>
<div v-else class="brand-popover__empty">
<a-empty :description="t('common.noData') || '暂无品牌'" />
</div>
</div>
</div>
</template>
</a-popover>
<a-button
v-else
size="small"
type="link"
class="current-brand-control__create"
:loading="companyStore.loading"
@click="handleCreateBrand"
>
{{ t('shell.createCompany') }}
</a-button>
</div>
<!-- Quota Indicator -->
<div v-if="!isMembershipBlocked" class="quota-pill">
<a-tag color="blue" :bordered="false" class="quota-pill-tag">
@@ -480,6 +736,26 @@ onBeforeUnmount(() => {
</template>
</a-menu>
</a-drawer>
<a-modal
v-model:open="brandModalOpen"
:title="t('brands.newBrand')"
centered
:confirm-loading="createBrandMutation.isPending.value"
@ok="submitBrand"
>
<a-form layout="vertical">
<a-form-item :label="t('brands.form.brandName')">
<a-input v-model:value="brandForm.name" />
</a-form-item>
<a-form-item :label="t('brands.form.brandWebsite')">
<a-input v-model:value="brandForm.website" />
</a-form-item>
<a-form-item :label="t('brands.form.brandDescription')">
<a-textarea v-model:value="brandForm.description" :rows="4" />
</a-form-item>
</a-form>
</a-modal>
</a-layout>
</template>
@@ -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;
}
}
</style>