feat(brand): add brand sort order and drag-to-reorder

Add a sort_order column to brands with a migration, expose a
PUT /api/tenant/brands/order reorder endpoint, and wire the
admin-web BrandsView with drag-and-drop reordering plus i18n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 21:48:20 +08:00
parent fbc69c01b0
commit 71233b6715
16 changed files with 608 additions and 31 deletions
+10
View File
@@ -1494,10 +1494,20 @@ const enUS = {
competitorLines: 'Product lines',
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: {
createBrand: 'Brand created.',
updateBrand: 'Brand updated.',
deleteBrand: 'Brand deleted.',
reorderBrand: 'Company display order updated.',
createKeyword: 'Keyword created.',
updateKeyword: 'Keyword updated.',
deleteKeyword: 'Keyword deleted.',
+10
View File
@@ -1430,10 +1430,20 @@ const zhCN = {
competitorLines: '重点产品线',
competitorLinesHint: '使用英文逗号分隔多个产品线',
},
order: {
eyebrow: '排序',
title: '公司展示顺序',
adjust: '调整排序',
done: '完成',
dragHandle: '拖动排序',
moveUp: '上移',
moveDown: '下移',
},
messages: {
createBrand: '品牌已创建',
updateBrand: '品牌已更新',
deleteBrand: '品牌已删除',
reorderBrand: '公司展示顺序已更新',
createKeyword: '关键词已创建',
updateKeyword: '关键词已更新',
deleteKeyword: '关键词已删除',
+4
View File
@@ -10,6 +10,7 @@ import type {
AuthTokens,
Brand,
BrandLibrarySummary,
BrandReorderRequest,
BrandRequest,
ChangePasswordRequest,
ClassifiedQuestion,
@@ -1158,6 +1159,9 @@ export const brandsApi = {
remove(id: number) {
return apiClient.remove<null>(`/api/tenant/brands/${id}`)
},
reorder(payload: BrandReorderRequest) {
return apiClient.put<Brand[], BrandReorderRequest>('/api/tenant/brands/order', payload)
},
listKeywords(brandId: number) {
return apiClient.get<Keyword[]>(`/api/tenant/brands/${brandId}/keywords`)
},
+8 -2
View File
@@ -59,8 +59,7 @@ export const useCompanyStore = defineStore('company', () => {
loading.value = true
try {
const nextBrands = normalizeBrandList(await brandsApi.list())
brands.value = nextBrands
syncCurrentBrand(nextBrands)
setBrands(nextBrands)
initialized.value = true
return nextBrands
} 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 {
brands.value = []
currentBrandId.value = null
@@ -84,6 +89,7 @@ export const useCompanyStore = defineStore('company', () => {
loading,
initialized,
refreshBrands,
setBrands,
setCurrentBrand,
reset,
}
+368 -8
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
CheckOutlined,
CloseOutlined,
DeleteOutlined,
EditOutlined,
HolderOutlined,
PlusOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
@@ -41,6 +46,10 @@ const questionEditOpen = ref(false)
const editingQuestionId = ref<number | null>(null)
const competitorModalOpen = ref(false)
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({
name: '',
@@ -106,6 +115,25 @@ const selectedBrand = computed(
() => 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 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 = {
createSingle: useMutation({
mutationFn: () =>
@@ -322,6 +363,16 @@ watch(
{ immediate: true },
)
watch(
() => brandList.value.map((brand) => brand.id).join(','),
() => {
if (!sortMode.value) {
draftBrandIds.value = currentBrandIds()
}
},
{ immediate: true },
)
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
const maxPage = Math.max(1, Math.ceil(total / pageSize))
if (questionPage.value > maxPage) {
@@ -344,6 +395,110 @@ function stringifyProductLines(value: unknown): string {
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 {
if (!brand && brandLimitReached.value) {
message.warning(
@@ -490,23 +645,98 @@ async function invalidateBrandQueries(): Promise<void> {
</section>
<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">
<a-skeleton v-for="item in 3" :key="item" active />
</div>
<div v-else-if="brandListQuery.data.value?.length" class="brand-grid">
<article
v-for="brand in brandListQuery.data.value"
v-for="(brand, index) in orderedBrands"
:key="brand.id"
class="brand-card"
:class="{ 'brand-card--active': brand.id === selectedBrandId }"
@click="selectBrand(brand.id)"
:class="{
'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>
<h3>{{ brand.name }}</h3>
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
<div class="brand-card__identity">
<div v-if="sortMode" class="brand-card__order">
<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 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-button
type="text"
@@ -533,7 +763,12 @@ async function invalidateBrandQueries(): Promise<void> {
</span>
</div>
</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 />
<span>{{ t('brands.actions.addBrand') }}</span>
</button>
@@ -808,6 +1043,28 @@ async function invalidateBrandQueries(): Promise<void> {
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 {
display: grid;
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);
}
.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 {
align-items: center;
justify-content: center;
@@ -860,6 +1134,80 @@ async function invalidateBrandQueries(): Promise<void> {
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 {
display: flex;
flex-wrap: wrap;
@@ -892,6 +1240,13 @@ async function invalidateBrandQueries(): Promise<void> {
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:focus-within .brand-card__actions {
opacity: 1;
@@ -970,11 +1325,16 @@ async function invalidateBrandQueries(): Promise<void> {
@media (max-width: 760px) {
.brand-page-head,
.brand-guide,
.brand-rail__toolbar,
.brand-detail__head {
flex-direction: column;
align-items: stretch;
}
.brand-rail__actions {
justify-content: flex-start;
}
.brand-detail__actions {
justify-content: flex-start;
}