Compare commits
3 Commits
4dece0f26a
...
b832e6dd07
| Author | SHA1 | Date | |
|---|---|---|---|
| b832e6dd07 | |||
| 71233b6715 | |||
| fbc69c01b0 |
@@ -798,7 +798,10 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
publish: {
|
publish: {
|
||||||
unpublished: 'Unpublished',
|
unpublished: 'Unpublished',
|
||||||
|
queued: 'Waiting to publish',
|
||||||
|
pending: 'Waiting to publish',
|
||||||
publishing: 'Publishing',
|
publishing: 'Publishing',
|
||||||
|
running: 'Publishing',
|
||||||
success: 'Published',
|
success: 'Published',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
partial_success: 'Partially Published',
|
partial_success: 'Partially Published',
|
||||||
@@ -806,7 +809,9 @@ const enUS = {
|
|||||||
publish_success: 'Published',
|
publish_success: 'Published',
|
||||||
publish_failed: 'Failed',
|
publish_failed: 'Failed',
|
||||||
pending_review: 'Pending Review',
|
pending_review: 'Pending Review',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
blocked_by_compliance: 'Blocked by compliance',
|
blocked_by_compliance: 'Blocked by compliance',
|
||||||
|
unknown: 'Unknown result',
|
||||||
},
|
},
|
||||||
sourceType: {
|
sourceType: {
|
||||||
template: 'Template generation',
|
template: 'Template generation',
|
||||||
@@ -1489,10 +1494,20 @@ const enUS = {
|
|||||||
competitorLines: 'Product lines',
|
competitorLines: 'Product lines',
|
||||||
competitorLinesHint: 'Separate multiple product lines with commas.',
|
competitorLinesHint: 'Separate multiple product lines with commas.',
|
||||||
},
|
},
|
||||||
|
order: {
|
||||||
|
eyebrow: 'Display Order',
|
||||||
|
title: 'Company display order',
|
||||||
|
adjust: 'Adjust order',
|
||||||
|
done: 'Done',
|
||||||
|
dragHandle: 'Drag to reorder',
|
||||||
|
moveUp: 'Move up',
|
||||||
|
moveDown: 'Move down',
|
||||||
|
},
|
||||||
messages: {
|
messages: {
|
||||||
createBrand: 'Brand created.',
|
createBrand: 'Brand created.',
|
||||||
updateBrand: 'Brand updated.',
|
updateBrand: 'Brand updated.',
|
||||||
deleteBrand: 'Brand deleted.',
|
deleteBrand: 'Brand deleted.',
|
||||||
|
reorderBrand: 'Company display order updated.',
|
||||||
createKeyword: 'Keyword created.',
|
createKeyword: 'Keyword created.',
|
||||||
updateKeyword: 'Keyword updated.',
|
updateKeyword: 'Keyword updated.',
|
||||||
deleteKeyword: 'Keyword deleted.',
|
deleteKeyword: 'Keyword deleted.',
|
||||||
|
|||||||
@@ -774,7 +774,10 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
publish: {
|
publish: {
|
||||||
unpublished: '未发布',
|
unpublished: '未发布',
|
||||||
|
queued: '等待发布',
|
||||||
|
pending: '等待发布',
|
||||||
publishing: '发布中',
|
publishing: '发布中',
|
||||||
|
running: '发布中',
|
||||||
success: '发布成功',
|
success: '发布成功',
|
||||||
failed: '发布失败',
|
failed: '发布失败',
|
||||||
partial_success: '部分发布',
|
partial_success: '部分发布',
|
||||||
@@ -782,7 +785,9 @@ const zhCN = {
|
|||||||
publish_success: '发布成功',
|
publish_success: '发布成功',
|
||||||
publish_failed: '发布失败',
|
publish_failed: '发布失败',
|
||||||
pending_review: '待审核',
|
pending_review: '待审核',
|
||||||
|
cancelled: '已取消',
|
||||||
blocked_by_compliance: '合规阻断',
|
blocked_by_compliance: '合规阻断',
|
||||||
|
unknown: '结果异常',
|
||||||
},
|
},
|
||||||
sourceType: {
|
sourceType: {
|
||||||
template: '模版单次生成',
|
template: '模版单次生成',
|
||||||
@@ -1425,10 +1430,20 @@ const zhCN = {
|
|||||||
competitorLines: '重点产品线',
|
competitorLines: '重点产品线',
|
||||||
competitorLinesHint: '使用英文逗号分隔多个产品线',
|
competitorLinesHint: '使用英文逗号分隔多个产品线',
|
||||||
},
|
},
|
||||||
|
order: {
|
||||||
|
eyebrow: '排序',
|
||||||
|
title: '公司展示顺序',
|
||||||
|
adjust: '调整排序',
|
||||||
|
done: '完成',
|
||||||
|
dragHandle: '拖动排序',
|
||||||
|
moveUp: '上移',
|
||||||
|
moveDown: '下移',
|
||||||
|
},
|
||||||
messages: {
|
messages: {
|
||||||
createBrand: '品牌已创建',
|
createBrand: '品牌已创建',
|
||||||
updateBrand: '品牌已更新',
|
updateBrand: '品牌已更新',
|
||||||
deleteBrand: '品牌已删除',
|
deleteBrand: '品牌已删除',
|
||||||
|
reorderBrand: '公司展示顺序已更新',
|
||||||
createKeyword: '关键词已创建',
|
createKeyword: '关键词已创建',
|
||||||
updateKeyword: '关键词已更新',
|
updateKeyword: '关键词已更新',
|
||||||
deleteKeyword: '关键词已删除',
|
deleteKeyword: '关键词已删除',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AuthTokens,
|
AuthTokens,
|
||||||
Brand,
|
Brand,
|
||||||
BrandLibrarySummary,
|
BrandLibrarySummary,
|
||||||
|
BrandReorderRequest,
|
||||||
BrandRequest,
|
BrandRequest,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
ClassifiedQuestion,
|
ClassifiedQuestion,
|
||||||
@@ -1158,6 +1159,9 @@ export const brandsApi = {
|
|||||||
remove(id: number) {
|
remove(id: number) {
|
||||||
return apiClient.remove<null>(`/api/tenant/brands/${id}`)
|
return apiClient.remove<null>(`/api/tenant/brands/${id}`)
|
||||||
},
|
},
|
||||||
|
reorder(payload: BrandReorderRequest) {
|
||||||
|
return apiClient.put<Brand[], BrandReorderRequest>('/api/tenant/brands/order', payload)
|
||||||
|
},
|
||||||
listKeywords(brandId: number) {
|
listKeywords(brandId: number) {
|
||||||
return apiClient.get<Keyword[]>(`/api/tenant/brands/${brandId}/keywords`)
|
return apiClient.get<Keyword[]>(`/api/tenant/brands/${brandId}/keywords`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ const generateStatusMap: Record<string, { label: string; color: string }> = {
|
|||||||
|
|
||||||
const publishStatusMap: Record<string, { label: string; color: string }> = {
|
const publishStatusMap: Record<string, { label: string; color: string }> = {
|
||||||
unpublished: { label: 'status.publish.unpublished', color: 'default' },
|
unpublished: { label: 'status.publish.unpublished', color: 'default' },
|
||||||
|
queued: { label: 'status.publish.queued', color: 'processing' },
|
||||||
|
pending: { label: 'status.publish.pending', color: 'processing' },
|
||||||
publishing: { label: 'status.publish.publishing', color: 'processing' },
|
publishing: { label: 'status.publish.publishing', color: 'processing' },
|
||||||
|
running: { label: 'status.publish.running', color: 'processing' },
|
||||||
success: { label: 'status.publish.success', color: 'success' },
|
success: { label: 'status.publish.success', color: 'success' },
|
||||||
failed: { label: 'status.publish.failed', color: 'error' },
|
failed: { label: 'status.publish.failed', color: 'error' },
|
||||||
partial_success: { label: 'status.publish.partial_success', color: 'warning' },
|
partial_success: { label: 'status.publish.partial_success', color: 'warning' },
|
||||||
@@ -23,7 +26,10 @@ const publishStatusMap: Record<string, { label: string; color: string }> = {
|
|||||||
publish_success: { label: 'status.publish.publish_success', color: 'success' },
|
publish_success: { label: 'status.publish.publish_success', color: 'success' },
|
||||||
publish_failed: { label: 'status.publish.publish_failed', color: 'error' },
|
publish_failed: { label: 'status.publish.publish_failed', color: 'error' },
|
||||||
pending_review: { label: 'status.publish.pending_review', color: 'warning' },
|
pending_review: { label: 'status.publish.pending_review', color: 'warning' },
|
||||||
|
cancelled: { label: 'status.publish.cancelled', color: 'default' },
|
||||||
|
canceled: { label: 'status.publish.cancelled', color: 'default' },
|
||||||
blocked_by_compliance: { label: 'status.publish.blocked_by_compliance', color: 'error' },
|
blocked_by_compliance: { label: 'status.publish.blocked_by_compliance', color: 'error' },
|
||||||
|
unknown: { label: 'status.publish.unknown', color: 'error' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const goTimestampWithZonePattern =
|
const goTimestampWithZonePattern =
|
||||||
@@ -87,7 +93,7 @@ export function getPublishStatusMeta(status?: string | null): { label: string; c
|
|||||||
if (!status) {
|
if (!status) {
|
||||||
return { label: '--', color: 'default' }
|
return { label: '--', color: 'default' }
|
||||||
}
|
}
|
||||||
const meta = publishStatusMap[status]
|
const meta = publishStatusMap[status.trim().toLowerCase()]
|
||||||
return meta
|
return meta
|
||||||
? { label: i18n.global.t(meta.label), color: meta.color }
|
? { label: i18n.global.t(meta.label), color: meta.color }
|
||||||
: { label: status, color: 'default' }
|
: { label: status, color: 'default' }
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ export const useCompanyStore = defineStore('company', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const nextBrands = normalizeBrandList(await brandsApi.list())
|
const nextBrands = normalizeBrandList(await brandsApi.list())
|
||||||
brands.value = nextBrands
|
setBrands(nextBrands)
|
||||||
syncCurrentBrand(nextBrands)
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
return nextBrands
|
return nextBrands
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,6 +67,12 @@ export const useCompanyStore = defineStore('company', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBrands(value: Brand[] | null | undefined): void {
|
||||||
|
const nextBrands = normalizeBrandList(value)
|
||||||
|
brands.value = nextBrands
|
||||||
|
syncCurrentBrand(nextBrands)
|
||||||
|
}
|
||||||
|
|
||||||
function reset(): void {
|
function reset(): void {
|
||||||
brands.value = []
|
brands.value = []
|
||||||
currentBrandId.value = null
|
currentBrandId.value = null
|
||||||
@@ -84,6 +89,7 @@ export const useCompanyStore = defineStore('company', () => {
|
|||||||
loading,
|
loading,
|
||||||
initialized,
|
initialized,
|
||||||
refreshBrands,
|
refreshBrands,
|
||||||
|
setBrands,
|
||||||
setCurrentBrand,
|
setCurrentBrand,
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
ArrowDownOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
HolderOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
@@ -41,6 +46,10 @@ const questionEditOpen = ref(false)
|
|||||||
const editingQuestionId = ref<number | null>(null)
|
const editingQuestionId = ref<number | null>(null)
|
||||||
const competitorModalOpen = ref(false)
|
const competitorModalOpen = ref(false)
|
||||||
const editingCompetitorId = ref<number | null>(null)
|
const editingCompetitorId = ref<number | null>(null)
|
||||||
|
const sortMode = ref(false)
|
||||||
|
const draftBrandIds = ref<number[]>([])
|
||||||
|
const draggedBrandId = ref<number | null>(null)
|
||||||
|
const dragOverBrandId = ref<number | null>(null)
|
||||||
|
|
||||||
const brandForm = reactive({
|
const brandForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -106,6 +115,25 @@ const selectedBrand = computed(
|
|||||||
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const brandList = computed(() => brandListQuery.data.value ?? [])
|
||||||
|
const orderedBrands = computed(() => {
|
||||||
|
if (!sortMode.value) {
|
||||||
|
return brandList.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandById = new Map(brandList.value.map((brand) => [brand.id, brand]))
|
||||||
|
const ordered = draftBrandIds.value
|
||||||
|
.map((brandId) => brandById.get(brandId))
|
||||||
|
.filter((brand): brand is Brand => Boolean(brand))
|
||||||
|
const orderedIds = new Set(ordered.map((brand) => brand.id))
|
||||||
|
const appended = brandList.value.filter((brand) => !orderedIds.has(brand.id))
|
||||||
|
return [...ordered, ...appended]
|
||||||
|
})
|
||||||
|
const canSortBrands = computed(() => brandList.value.length > 1)
|
||||||
|
const hasBrandOrderChanges = computed(
|
||||||
|
() => !isSameBrandOrder(draftBrandIds.value, brandList.value.map((brand) => brand.id)),
|
||||||
|
)
|
||||||
|
|
||||||
const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
|
const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
|
||||||
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
|
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
|
||||||
|
|
||||||
@@ -195,6 +223,19 @@ const brandMutations = {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const brandOrderMutation = useMutation({
|
||||||
|
mutationFn: (brandIds: number[]) => brandsApi.reorder({ brand_ids: brandIds }),
|
||||||
|
onSuccess: async (brands) => {
|
||||||
|
message.success(t('brands.messages.reorderBrand'))
|
||||||
|
queryClient.setQueryData(['brands', 'list'], brands)
|
||||||
|
companyStore.setBrands(brands)
|
||||||
|
sortMode.value = false
|
||||||
|
resetBrandDragState()
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['brands'] })
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(formatError(error)),
|
||||||
|
})
|
||||||
|
|
||||||
const questionMutations = {
|
const questionMutations = {
|
||||||
createSingle: useMutation({
|
createSingle: useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -322,6 +363,16 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => brandList.value.map((brand) => brand.id).join(','),
|
||||||
|
() => {
|
||||||
|
if (!sortMode.value) {
|
||||||
|
draftBrandIds.value = currentBrandIds()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
|
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
|
||||||
const maxPage = Math.max(1, Math.ceil(total / pageSize))
|
const maxPage = Math.max(1, Math.ceil(total / pageSize))
|
||||||
if (questionPage.value > maxPage) {
|
if (questionPage.value > maxPage) {
|
||||||
@@ -344,6 +395,110 @@ function stringifyProductLines(value: unknown): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentBrandIds(): number[] {
|
||||||
|
return brandList.value.map((brand) => brand.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameBrandOrder(left: number[], right: number[]): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left.every((brandId, index) => brandId === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBrandDragState(): void {
|
||||||
|
draggedBrandId.value = null
|
||||||
|
dragOverBrandId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSortMode(): void {
|
||||||
|
if (!canSortBrands.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftBrandIds.value = currentBrandIds()
|
||||||
|
sortMode.value = true
|
||||||
|
resetBrandDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSortMode(): void {
|
||||||
|
draftBrandIds.value = currentBrandIds()
|
||||||
|
sortMode.value = false
|
||||||
|
resetBrandDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBrandOrder(): Promise<void> {
|
||||||
|
const brandIds = orderedBrands.value.map((brand) => brand.id)
|
||||||
|
if (!isSameBrandOrder(brandIds, currentBrandIds())) {
|
||||||
|
await brandOrderMutation.mutateAsync(brandIds)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelSortMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDraftBrand(brandId: number, direction: -1 | 1): void {
|
||||||
|
const nextIds = [...draftBrandIds.value]
|
||||||
|
const currentIndex = nextIds.indexOf(brandId)
|
||||||
|
const nextIndex = currentIndex + direction
|
||||||
|
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= nextIds.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [movedId] = nextIds.splice(currentIndex, 1)
|
||||||
|
nextIds.splice(nextIndex, 0, movedId)
|
||||||
|
draftBrandIds.value = nextIds
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDraftBrandToTarget(sourceBrandId: number, targetBrandId: number): void {
|
||||||
|
if (sourceBrandId === targetBrandId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextIds = [...draftBrandIds.value]
|
||||||
|
const sourceIndex = nextIds.indexOf(sourceBrandId)
|
||||||
|
const targetIndex = nextIds.indexOf(targetBrandId)
|
||||||
|
if (sourceIndex < 0 || targetIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [movedId] = nextIds.splice(sourceIndex, 1)
|
||||||
|
nextIds.splice(targetIndex, 0, movedId)
|
||||||
|
draftBrandIds.value = nextIds
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrandCardClick(brandId: number): void {
|
||||||
|
if (sortMode.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectBrand(brandId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrandDragStart(brandId: number, event: DragEvent): void {
|
||||||
|
draggedBrandId.value = brandId
|
||||||
|
dragOverBrandId.value = null
|
||||||
|
event.dataTransfer?.setData('text/plain', String(brandId))
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrandDragOver(brandId: number, event: DragEvent): void {
|
||||||
|
if (!sortMode.value || draggedBrandId.value === null || draggedBrandId.value === brandId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
dragOverBrandId.value = brandId
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrandDrop(brandId: number, event: DragEvent): void {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!sortMode.value || draggedBrandId.value === null) {
|
||||||
|
resetBrandDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveDraftBrandToTarget(draggedBrandId.value, brandId)
|
||||||
|
resetBrandDragState()
|
||||||
|
}
|
||||||
|
|
||||||
function openBrandModal(brand?: Brand): void {
|
function openBrandModal(brand?: Brand): void {
|
||||||
if (!brand && brandLimitReached.value) {
|
if (!brand && brandLimitReached.value) {
|
||||||
message.warning(
|
message.warning(
|
||||||
@@ -490,23 +645,98 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="brand-rail">
|
<section class="brand-rail">
|
||||||
|
<div class="brand-rail__toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ t('brands.order.eyebrow') }}</p>
|
||||||
|
<h3>{{ t('brands.order.title') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="brand-rail__actions">
|
||||||
|
<template v-if="sortMode">
|
||||||
|
<a-button :disabled="brandOrderMutation.isPending.value" @click="cancelSortMode">
|
||||||
|
<template #icon><CloseOutlined /></template>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!hasBrandOrderChanges"
|
||||||
|
:loading="brandOrderMutation.isPending.value"
|
||||||
|
@click="saveBrandOrder"
|
||||||
|
>
|
||||||
|
<template #icon><CheckOutlined /></template>
|
||||||
|
{{ t('brands.order.done') }}
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<a-button v-else :disabled="!canSortBrands" @click="startSortMode">
|
||||||
|
<template #icon><HolderOutlined /></template>
|
||||||
|
{{ t('brands.order.adjust') }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="brandListQuery.isPending.value" class="brand-grid">
|
<div v-if="brandListQuery.isPending.value" class="brand-grid">
|
||||||
<a-skeleton v-for="item in 3" :key="item" active />
|
<a-skeleton v-for="item in 3" :key="item" active />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="brandListQuery.data.value?.length" class="brand-grid">
|
<div v-else-if="brandListQuery.data.value?.length" class="brand-grid">
|
||||||
<article
|
<article
|
||||||
v-for="brand in brandListQuery.data.value"
|
v-for="(brand, index) in orderedBrands"
|
||||||
:key="brand.id"
|
:key="brand.id"
|
||||||
class="brand-card"
|
class="brand-card"
|
||||||
:class="{ 'brand-card--active': brand.id === selectedBrandId }"
|
:class="{
|
||||||
@click="selectBrand(brand.id)"
|
'brand-card--active': brand.id === selectedBrandId,
|
||||||
|
'brand-card--sorting': sortMode,
|
||||||
|
'brand-card--dragging': draggedBrandId === brand.id,
|
||||||
|
'brand-card--drop-target': dragOverBrandId === brand.id,
|
||||||
|
}"
|
||||||
|
@click="handleBrandCardClick(brand.id)"
|
||||||
|
@dragover="handleBrandDragOver(brand.id, $event)"
|
||||||
|
@drop="handleBrandDrop(brand.id, $event)"
|
||||||
|
@dragend="resetBrandDragState"
|
||||||
>
|
>
|
||||||
<div class="brand-card__top">
|
<div class="brand-card__top">
|
||||||
<div>
|
<div class="brand-card__identity">
|
||||||
<h3>{{ brand.name }}</h3>
|
<div v-if="sortMode" class="brand-card__order">
|
||||||
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="brand-card__drag-handle"
|
||||||
|
draggable="true"
|
||||||
|
:aria-label="t('brands.order.dragHandle')"
|
||||||
|
@click.stop
|
||||||
|
@dragstart.stop="handleBrandDragStart(brand.id, $event)"
|
||||||
|
@dragend="resetBrandDragState"
|
||||||
|
>
|
||||||
|
<HolderOutlined />
|
||||||
|
</button>
|
||||||
|
<span>{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="brand-card__copy">
|
||||||
|
<h3>{{ brand.name }}</h3>
|
||||||
|
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-card__actions">
|
<div v-if="sortMode" class="brand-card__sort-actions">
|
||||||
|
<a-tooltip :title="t('brands.order.moveUp')">
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click.stop="moveDraftBrand(brand.id, -1)"
|
||||||
|
>
|
||||||
|
<ArrowUpOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('brands.order.moveDown')">
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
:disabled="index === orderedBrands.length - 1"
|
||||||
|
@click.stop="moveDraftBrand(brand.id, 1)"
|
||||||
|
>
|
||||||
|
<ArrowDownOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div v-else class="brand-card__actions">
|
||||||
<a-tooltip :title="t('common.edit')">
|
<a-tooltip :title="t('common.edit')">
|
||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -533,7 +763,12 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<button type="button" class="brand-card brand-card--add" @click="openBrandModal()">
|
<button
|
||||||
|
v-if="!sortMode"
|
||||||
|
type="button"
|
||||||
|
class="brand-card brand-card--add"
|
||||||
|
@click="openBrandModal()"
|
||||||
|
>
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
<span>{{ t('brands.actions.addBrand') }}</span>
|
<span>{{ t('brands.actions.addBrand') }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -808,6 +1043,28 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-rail__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-rail__toolbar h3 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 740;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-rail__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-grid {
|
.brand-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -843,6 +1100,23 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
|
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-card--sorting {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card--dragging {
|
||||||
|
opacity: 0.46;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card--drop-target {
|
||||||
|
border-color: #1677ff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(22, 119, 255, 0.18),
|
||||||
|
0 14px 28px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.brand-card--add {
|
.brand-card--add {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -860,6 +1134,80 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-card__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__order {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__order span {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 20px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
background: #eff6ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__drag-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: grab;
|
||||||
|
transition:
|
||||||
|
color 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__drag-handle :deep(.anticon) {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__drag-handle :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__drag-handle:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
background: #eef6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card__drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-detail__actions {
|
.brand-detail__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -892,6 +1240,13 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
transition: opacity 0.18s ease;
|
transition: opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-card__sort-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-card:hover .brand-card__actions,
|
.brand-card:hover .brand-card__actions,
|
||||||
.brand-card:focus-within .brand-card__actions {
|
.brand-card:focus-within .brand-card__actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -970,11 +1325,16 @@ async function invalidateBrandQueries(): Promise<void> {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.brand-page-head,
|
.brand-page-head,
|
||||||
.brand-guide,
|
.brand-guide,
|
||||||
|
.brand-rail__toolbar,
|
||||||
.brand-detail__head {
|
.brand-detail__head {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-rail__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-detail__actions {
|
.brand-detail__actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1695,6 +1695,7 @@ export interface Brand {
|
|||||||
website: string | null
|
website: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
status: string
|
status: string
|
||||||
|
sort_order?: number
|
||||||
keyword_count: number
|
keyword_count: number
|
||||||
question_count: number
|
question_count: number
|
||||||
competitor_count?: number
|
competitor_count?: number
|
||||||
@@ -1702,6 +1703,10 @@ export interface Brand {
|
|||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrandReorderRequest {
|
||||||
|
brand_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface KeywordRequest {
|
export interface KeywordRequest {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type BrandResponse struct {
|
|||||||
Website *string `json:"website"`
|
Website *string `json:"website"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
KeywordCount int `json:"keyword_count"`
|
KeywordCount int `json:"keyword_count"`
|
||||||
QuestionCount int `json:"question_count"`
|
QuestionCount int `json:"question_count"`
|
||||||
CompetitorCount int `json:"competitor_count"`
|
CompetitorCount int `json:"competitor_count"`
|
||||||
@@ -80,6 +81,10 @@ type BrandResponse struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BrandReorderRequest struct {
|
||||||
|
BrandIDs []int64 `json:"brand_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type BrandLibrarySummaryResponse struct {
|
type BrandLibrarySummaryResponse struct {
|
||||||
PlanCode string `json:"plan_code"`
|
PlanCode string `json:"plan_code"`
|
||||||
PlanName string `json:"plan_name"`
|
PlanName string `json:"plan_name"`
|
||||||
@@ -155,6 +160,7 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
|
var sortOrder int
|
||||||
var ca interface{}
|
var ca interface{}
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
WITH usage AS (
|
WITH usage AS (
|
||||||
@@ -162,12 +168,22 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
|||||||
FROM brands
|
FROM brands
|
||||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
)
|
)
|
||||||
INSERT INTO brands (tenant_id, name, website, description, status)
|
INSERT INTO brands (tenant_id, name, website, description, status, sort_order)
|
||||||
SELECT $1, $2, $3, $4, 'active'
|
SELECT
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
'active',
|
||||||
|
COALESCE((
|
||||||
|
SELECT MAX(sort_order) + 1000
|
||||||
|
FROM brands
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
|
), 1000)
|
||||||
FROM usage
|
FROM usage
|
||||||
WHERE usage.used_brands < $5
|
WHERE usage.used_brands < $5
|
||||||
RETURNING id, created_at
|
RETURNING id, sort_order, created_at
|
||||||
`, actor.TenantID, req.Name, req.Website, req.Description, summary.MaxBrands).Scan(&id, &ca)
|
`, actor.TenantID, req.Name, req.Website, req.Description, summary.MaxBrands).Scan(&id, &sortOrder, &ca)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, response.ErrConflict(40904, "brand_limit_reached", fmt.Sprintf("current plan allows up to %d brand companies", summary.MaxBrands))
|
return nil, response.ErrConflict(40904, "brand_limit_reached", fmt.Sprintf("current plan allows up to %d brand companies", summary.MaxBrands))
|
||||||
@@ -208,6 +224,7 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
|||||||
Website: req.Website,
|
Website: req.Website,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
SortOrder: sortOrder,
|
||||||
KeywordCount: 0,
|
KeywordCount: 0,
|
||||||
QuestionCount: 0,
|
QuestionCount: 0,
|
||||||
CompetitorCount: 0,
|
CompetitorCount: 0,
|
||||||
@@ -215,6 +232,112 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *BrandService) Reorder(ctx context.Context, req BrandReorderRequest) ([]BrandResponse, error) {
|
||||||
|
actor := auth.MustActor(ctx)
|
||||||
|
brandIDs, err := normalizeBrandOrderIDs(req.BrandIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to begin brand reorder transaction")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, fmt.Sprintf("tenant:%d", actor.TenantID), "brand_order"); err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to lock brand order")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT id
|
||||||
|
FROM brands
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY sort_order ASC, created_at DESC, id DESC
|
||||||
|
FOR UPDATE
|
||||||
|
`, actor.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to load current brand order")
|
||||||
|
}
|
||||||
|
activeBrandIDs := make([]int64, 0, len(brandIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to scan current brand order")
|
||||||
|
}
|
||||||
|
activeBrandIDs = append(activeBrandIDs, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to iterate current brand order")
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if !sameIDSet(brandIDs, activeBrandIDs) {
|
||||||
|
return nil, response.ErrBadRequest(40003, "brand_order_stale", "brand order must include all active brands")
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, brandID := range brandIDs {
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE brands
|
||||||
|
SET sort_order = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
||||||
|
`, (index+1)*1000, brandID, actor.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to update brand order")
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "reorder_failed", "failed to commit brand order")
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, 0)
|
||||||
|
return s.loadBrands(ctx, actor.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBrandOrderIDs(values []int64) ([]int64, error) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, response.ErrBadRequest(40001, "invalid_params", "brand_ids is required")
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(values))
|
||||||
|
result := make([]int64, 0, len(values))
|
||||||
|
for _, id := range values {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, response.ErrBadRequest(40001, "invalid_params", "brand_ids must contain positive integers")
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
return nil, response.ErrBadRequest(40001, "invalid_params", "brand_ids must not contain duplicates")
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameIDSet(left []int64, right []int64) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(left))
|
||||||
|
for _, id := range left {
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, id := range right {
|
||||||
|
if _, ok := seen[id]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) {
|
func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, brandDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*BrandResponse, bool, error) {
|
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, brandDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*BrandResponse, bool, error) {
|
||||||
@@ -799,6 +922,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
|||||||
b.website,
|
b.website,
|
||||||
b.description,
|
b.description,
|
||||||
b.status,
|
b.status,
|
||||||
|
b.sort_order,
|
||||||
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
||||||
COALESCE(question_stats.question_count, 0) AS question_count,
|
COALESCE(question_stats.question_count, 0) AS question_count,
|
||||||
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
||||||
@@ -829,7 +953,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
|||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
) competitor_stats ON true
|
) competitor_stats ON true
|
||||||
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
|
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.created_at DESC
|
ORDER BY b.sort_order ASC, b.created_at DESC, b.id DESC
|
||||||
`, tenantID)
|
`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list brands")
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list brands")
|
||||||
@@ -841,7 +965,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
|||||||
var item BrandResponse
|
var item BrandResponse
|
||||||
var createdAt interface{}
|
var createdAt interface{}
|
||||||
var updatedAt interface{}
|
var updatedAt interface{}
|
||||||
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
|
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.SortOrder, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
|
||||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||||
}
|
}
|
||||||
item.CreatedAt = formatBrandTime(createdAt)
|
item.CreatedAt = formatBrandTime(createdAt)
|
||||||
@@ -862,6 +986,7 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
|
|||||||
b.website,
|
b.website,
|
||||||
b.description,
|
b.description,
|
||||||
b.status,
|
b.status,
|
||||||
|
b.sort_order,
|
||||||
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
||||||
COALESCE(question_stats.question_count, 0) AS question_count,
|
COALESCE(question_stats.question_count, 0) AS question_count,
|
||||||
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
||||||
@@ -892,7 +1017,7 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
|
|||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
) competitor_stats ON true
|
) competitor_stats ON true
|
||||||
WHERE b.id = $1 AND b.tenant_id = $2 AND b.deleted_at IS NULL
|
WHERE b.id = $1 AND b.tenant_id = $2 AND b.deleted_at IS NULL
|
||||||
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt)
|
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.SortOrder, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Brand struct {
|
|||||||
Website *string
|
Website *string
|
||||||
Description *string
|
Description *string
|
||||||
Status string
|
Status string
|
||||||
|
SortOrder int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,26 +12,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const createBrand = `-- name: CreateBrand :one
|
const createBrand = `-- name: CreateBrand :one
|
||||||
INSERT INTO brands (tenant_id, name, description, status)
|
INSERT INTO brands (tenant_id, name, description, status, sort_order)
|
||||||
VALUES ($1, $2, $3, 'active')
|
VALUES ($1, $2, $3, 'active', $4)
|
||||||
RETURNING id, created_at
|
RETURNING id, sort_order, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateBrandParams struct {
|
type CreateBrandParams struct {
|
||||||
TenantID int64 `json:"tenant_id"`
|
TenantID int64 `json:"tenant_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBrandRow struct {
|
type CreateBrandRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateBrand(ctx context.Context, arg CreateBrandParams) (CreateBrandRow, error) {
|
func (q *Queries) CreateBrand(ctx context.Context, arg CreateBrandParams) (CreateBrandRow, error) {
|
||||||
row := q.db.QueryRow(ctx, createBrand, arg.TenantID, arg.Name, arg.Description)
|
row := q.db.QueryRow(ctx, createBrand,
|
||||||
|
arg.TenantID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Description,
|
||||||
|
arg.SortOrder,
|
||||||
|
)
|
||||||
var i CreateBrandRow
|
var i CreateBrandRow
|
||||||
err := row.Scan(&i.ID, &i.CreatedAt)
|
err := row.Scan(&i.ID, &i.SortOrder, &i.CreatedAt)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +126,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getBrandByID = `-- name: GetBrandByID :one
|
const getBrandByID = `-- name: GetBrandByID :one
|
||||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||||
FROM brands
|
FROM brands
|
||||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
@@ -135,6 +142,7 @@ type GetBrandByIDRow struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -148,6 +156,7 @@ func (q *Queries) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (Get
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.SortOrder,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -155,10 +164,10 @@ func (q *Queries) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (Get
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listBrands = `-- name: ListBrands :many
|
const listBrands = `-- name: ListBrands :many
|
||||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||||
FROM brands
|
FROM brands
|
||||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
ORDER BY created_at DESC
|
ORDER BY sort_order ASC, created_at DESC, id DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListBrandsRow struct {
|
type ListBrandsRow struct {
|
||||||
@@ -167,6 +176,7 @@ type ListBrandsRow struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -186,6 +196,7 @@ func (q *Queries) ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsR
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.SortOrder,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type Brand struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
Website pgtype.Text `json:"website"`
|
Website pgtype.Text `json:"website"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrandAssetCleanupEvent struct {
|
type BrandAssetCleanupEvent struct {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
-- name: ListBrands :many
|
-- name: ListBrands :many
|
||||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||||
FROM brands
|
FROM brands
|
||||||
WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL
|
WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL
|
||||||
ORDER BY created_at DESC;
|
ORDER BY sort_order ASC, created_at DESC, id DESC;
|
||||||
|
|
||||||
-- name: GetBrandByID :one
|
-- name: GetBrandByID :one
|
||||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||||
FROM brands
|
FROM brands
|
||||||
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
|
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
|
||||||
|
|
||||||
-- name: CreateBrand :one
|
-- name: CreateBrand :one
|
||||||
INSERT INTO brands (tenant_id, name, description, status)
|
INSERT INTO brands (tenant_id, name, description, status, sort_order)
|
||||||
VALUES (sqlc.arg(tenant_id), sqlc.arg(name), sqlc.arg(description), 'active')
|
VALUES (sqlc.arg(tenant_id), sqlc.arg(name), sqlc.arg(description), 'active', sqlc.arg(sort_order))
|
||||||
RETURNING id, created_at;
|
RETURNING id, sort_order, created_at;
|
||||||
|
|
||||||
-- name: UpdateBrand :exec
|
-- name: UpdateBrand :exec
|
||||||
UPDATE brands SET name = sqlc.arg(name), description = sqlc.arg(description), updated_at = NOW()
|
UPDATE brands SET name = sqlc.arg(name), description = sqlc.arg(description), updated_at = NOW()
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ func (h *BrandHandler) Create(c *gin.Context) {
|
|||||||
response.SuccessWithStatus(c, 201, data)
|
response.SuccessWithStatus(c, 201, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *BrandHandler) Reorder(c *gin.Context) {
|
||||||
|
var req app.BrandReorderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.svc.Reorder(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *BrandHandler) Detail(c *gin.Context) {
|
func (h *BrandHandler) Detail(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
brands.GET("", brandHandler.List)
|
brands.GET("", brandHandler.List)
|
||||||
brands.GET("/library-summary", brandHandler.Summary)
|
brands.GET("/library-summary", brandHandler.Summary)
|
||||||
brands.POST("", brandHandler.Create)
|
brands.POST("", brandHandler.Create)
|
||||||
|
brands.PUT("/order", brandHandler.Reorder)
|
||||||
brands.GET("/:id", brandHandler.Detail)
|
brands.GET("/:id", brandHandler.Detail)
|
||||||
brands.PUT("/:id", brandHandler.Update)
|
brands.PUT("/:id", brandHandler.Update)
|
||||||
brands.DELETE("/:id", brandHandler.Delete)
|
brands.DELETE("/:id", brandHandler.Delete)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
|
|||||||
{http.MethodGet, "/api/tenant/articles"},
|
{http.MethodGet, "/api/tenant/articles"},
|
||||||
{http.MethodGet, "/api/tenant/brands"},
|
{http.MethodGet, "/api/tenant/brands"},
|
||||||
{http.MethodGet, "/api/tenant/brands/library-summary"},
|
{http.MethodGet, "/api/tenant/brands/library-summary"},
|
||||||
|
{http.MethodPut, "/api/tenant/brands/order"},
|
||||||
{http.MethodGet, "/api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail"},
|
{http.MethodGet, "/api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail"},
|
||||||
{http.MethodGet, "/api/tenant/monitoring/marked-articles"},
|
{http.MethodGet, "/api/tenant/monitoring/marked-articles"},
|
||||||
{http.MethodPost, "/api/tenant/monitoring/marked-articles"},
|
{http.MethodPost, "/api/tenant/monitoring/marked-articles"},
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_brands_tenant_sort_active;
|
||||||
|
|
||||||
|
ALTER TABLE brands
|
||||||
|
DROP COLUMN IF EXISTS sort_order;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
ALTER TABLE brands
|
||||||
|
ADD COLUMN sort_order INT;
|
||||||
|
|
||||||
|
WITH ordered_brands AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY tenant_id
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
)::INT * 1000 AS next_sort_order
|
||||||
|
FROM brands
|
||||||
|
)
|
||||||
|
UPDATE brands
|
||||||
|
SET sort_order = ordered_brands.next_sort_order
|
||||||
|
FROM ordered_brands
|
||||||
|
WHERE brands.id = ordered_brands.id;
|
||||||
|
|
||||||
|
ALTER TABLE brands
|
||||||
|
ALTER COLUMN sort_order SET DEFAULT 1000,
|
||||||
|
ALTER COLUMN sort_order SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_brands_tenant_sort_active
|
||||||
|
ON brands(tenant_id, sort_order ASC, created_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
Reference in New Issue
Block a user