diff --git a/AGENTS.md b/AGENTS.md index 8e25ebb..93e5f1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ### 1. 梳理需求 -使用 brainstorming 进行头脑风暴, 文档存放与命名规则如下: +使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下: | 需求类型 | 计划目录 | 命名规则 | 示例 | | -------- | ----------------- | --------------------- | ----------------------- | diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..d72d0a9 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +brand + +## Users + +跨境电商、POD 卖家和需要小批量定制履约能力的商家。他们通过官网了解 InkReach 的商品、生产、设计与全球配送能力,并进入 InkPOD 完成实际业务操作。 + +## Product Purpose + +官网用于清晰展示 InkReach 的一站式 POD 能力,让访客快速浏览货盘和案例,并以最短路径进入 InkPOD 登录及商品详情页面。 + +## Brand Personality + +直接、可靠、务实。页面应保留鲜明的橙色品牌识别,同时让商品和真实业务数据成为视觉重点。 + +## Anti-references + +避免与 Figma 设计稿无关的通用 SaaS 模板、纯装饰性渐变、过度卡片化、被拉伸模糊的位图,以及拥挤到妨碍浏览的桌面布局。 + +## Design Principles + +- 商品与业务信息优先于装饰。 +- 关键操作即时、明确,并保持当前页面跳转语义一致。 +- 宽屏增加呼吸空间,不通过无限放大素材填满画面。 +- 动效用于展示连续内容,不影响用户主动操作。 +- 后台配置是商品、国家、品类和标签的唯一数据来源。 + +## Accessibility & Inclusion + +以 WCAG 2.1 AA 为基准,维持键盘可操作性、足够对比度,并为持续动画提供 `prefers-reduced-motion` 降级。 diff --git a/apps/admin/public/logo-unified.svg b/apps/admin/public/logo-unified.svg new file mode 100644 index 0000000..40c805a --- /dev/null +++ b/apps/admin/public/logo-unified.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/src/api/goods.ts b/apps/admin/src/api/goods.ts index 558152a..f575070 100644 --- a/apps/admin/src/api/goods.ts +++ b/apps/admin/src/api/goods.ts @@ -4,7 +4,7 @@ import type { CreateGoodRequest, UpdateGoodRequest, BatchCreateGoodsRequest, - UpdatePriorityRequest, + BatchPriorityRequest, GoodsFilter, PaginatedResult, } from '@/types' @@ -40,8 +40,8 @@ export const goodsApi = { return request.post('/goods/batch', data) }, - // Update priority - updatePriority: (data: UpdatePriorityRequest) => { - return request.patch('/goods/priority', data) + // Batch update priority + batchUpdatePriority: (data: BatchPriorityRequest) => { + return request.patch('/goods/batch-priority', data) }, } \ No newline at end of file diff --git a/apps/admin/src/api/sync.ts b/apps/admin/src/api/sync.ts index d36bde1..0b83dc5 100644 --- a/apps/admin/src/api/sync.ts +++ b/apps/admin/src/api/sync.ts @@ -3,7 +3,11 @@ import type { SyncLog } from '@/types' export const syncApi = { syncProducts: () => { - return request.post('/sync/products') + return request.post('/sync/products') + }, + + syncCategories: () => { + return request.post('/sync/categories') }, getSyncStatus: (limit?: number) => { diff --git a/apps/admin/src/api/upload.ts b/apps/admin/src/api/upload.ts new file mode 100644 index 0000000..12e06cf --- /dev/null +++ b/apps/admin/src/api/upload.ts @@ -0,0 +1,14 @@ +import request from './request' + +export interface UploadResult { + url: string + filename: string +} + +export const uploadApi = { + uploadImage: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return request.post('/upload/image', formData) + }, +} diff --git a/apps/admin/src/components.d.ts b/apps/admin/src/components.d.ts index f749eb4..0cf076e 100644 --- a/apps/admin/src/components.d.ts +++ b/apps/admin/src/components.d.ts @@ -15,7 +15,6 @@ declare module 'vue' { ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCard: typeof import('element-plus/es')['ElCard'] ElCascader: typeof import('element-plus/es')['ElCascader'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] @@ -30,23 +29,24 @@ declare module 'vue' { ElHeader: typeof import('element-plus/es')['ElHeader'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElImage: typeof import('element-plus/es')['ElImage'] + ElImageViewer: typeof import('element-plus/es')['ElImageViewer'] ElInput: typeof import('element-plus/es')['ElInput'] - ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] - ElTable: typeof import('element-plus/es')['ElTable'] - ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTree: typeof import('element-plus/es')['ElTree'] + ElUpload: typeof import('element-plus/es')['ElUpload'] + ImageUpload: typeof import('./components/ImageUpload.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/apps/admin/src/components/ImageUpload.vue b/apps/admin/src/components/ImageUpload.vue new file mode 100644 index 0000000..32c8c22 --- /dev/null +++ b/apps/admin/src/components/ImageUpload.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/apps/admin/src/layouts/DefaultLayout.vue b/apps/admin/src/layouts/DefaultLayout.vue index 0f3f602..7d92381 100644 --- a/apps/admin/src/layouts/DefaultLayout.vue +++ b/apps/admin/src/layouts/DefaultLayout.vue @@ -71,11 +71,8 @@ function handleCommand(command: string) {
-
IR
-
-
Inkreach
-
官网后台
-
+ + 印美达官网后台
import { computed, nextTick, onMounted, ref, watch } from 'vue' +import { useVirtualList } from '@vueuse/core' import { ElMessage, ElMessageBox } from 'element-plus' import { - Plus, Edit, Delete, Search, - FolderAdd, Aim, + Plus, Edit, Delete, Search, Top, + FolderAdd, Aim, ArrowDown, } from '@element-plus/icons-vue' import type { CategoryTree, Country, Tag, TagGroup, Good, Position, @@ -17,7 +18,7 @@ import { tagGroupsApi } from '@/api/tag-groups' import { positionsApi } from '@/api/positions' import { originGoodsApi } from '@/api/origin-goods' -const mode = ref<'category' | 'country'>('category') +const mode = ref<'category' | 'country' | 'global'>('category') const loading = ref(false) const leftTreeRef = ref() @@ -32,12 +33,26 @@ const allGoods = ref([]) const searchKeyword = ref('') const selectedCountryIds = ref([]) const selectedTagIds = ref([]) +const sortBy = ref(localStorage.getItem('goods-sort-by') || 'default') + +const SORT_OPTIONS = [ + { label: '默认排序', value: 'default' }, + { label: '优先级 高→低', value: 'priority-desc' }, + { label: '优先级 低→高', value: 'priority-asc' }, + { label: '名称 A→Z', value: 'name-asc' }, + { label: '名称 Z→A', value: 'name-desc' }, + { label: '最新创建', value: 'created-desc' }, + { label: '最早创建', value: 'created-asc' }, +] + +watch(sortBy, (val) => localStorage.setItem('goods-sort-by', val)) const treeProps = { label: 'label', children: 'children' } const leftPct = ref(55) const showAllLeft = ref(false) const showAllRight = ref(false) +const showUnconfiguredOnly = ref(false) function onSplitterMouseDown(e: MouseEvent) { e.preventDefault() @@ -62,16 +77,21 @@ function onSplitterMouseDown(e: MouseEvent) { function setExpand(treeRef: any, data: any[], expand: boolean) { nextTick(() => { - if (!treeRef.value) return - if (expand) { - const keys: string[] = [] - function collect(arr: any[]) { - for (const n of arr) { keys.push(n.id); if (n.children) collect(n.children) } + const tree = treeRef?.value ?? treeRef + if (!tree?.getNode) return + const keys: string[] = [] + function collect(arr: any[]) { + for (const n of arr) { + keys.push(n.id) + if (n.children?.length) collect(n.children) + } + } + collect(data) + for (const key of keys) { + const node = tree.getNode(key) + if (node && node.childNodes?.length) { + node.expanded = expand } - collect(data) - treeRef.value.setExpandedKeys(keys) - } else { - treeRef.value.setExpandedKeys([]) } }) } @@ -106,6 +126,31 @@ async function loadAll() { finally { loading.value = false } } +// Set of origin good IDs currently visible in the right tree (active/non-delisted) +const activeOriginGoodIds = computed(() => { + const ids = new Set() + function traverse(nodes: any[]) { + for (const n of nodes) { + if (n.isOG && n.rawId) ids.add(String(n.rawId)) + if (n.children?.length) traverse(n.children) + } + } + traverse(rightTreeData.value) + return ids +}) + +// Sort tags by their group's sortOrder, then by tag's sortOrder within group +function sortTagsByGroup(tags: Tag[]): Tag[] { + const groupOrder = new Map() + allTagGroups.value.forEach((g, i) => groupOrder.set(g.id, i)) + return [...tags].sort((a, b) => { + const ga = a.tagGroupId ? (groupOrder.get(a.tagGroupId) ?? 999) : 999 + const gb = b.tagGroupId ? (groupOrder.get(b.tagGroupId) ?? 999) : 999 + if (ga !== gb) return ga - gb + return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + }) +} + function goodToNode(g: Good): any { return { id: 'good-' + g.id, @@ -116,17 +161,18 @@ function goodToNode(g: Good): any { goodName: g.goodName, goodImage: g.goodImage || g.originGood?.goodImage || null, country: g.country?.countryName || g.countryId, - tags: g.tags || [], + tags: sortTagsByGroup(g.tags || []), priority: g.goodPriority, originGoodId: g.originGoodId, originGoodName: g.originGood?.goodName || null, originGoodImage: g.originGood?.goodImage || null, originGoodPrice: g.originGood?.goodPrice || null, sdsGoodId: g.originGood?.sdsGoodId || null, + originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)), } } -// Computed: goods after applying search + country + tag filters +// Computed: goods after applying search + country + tag filters + sort const filteredGoods = computed(() => { let result = allGoods.value if (searchKeyword.value) { @@ -141,7 +187,28 @@ const filteredGoods = computed(() => { return selectedTagIds.value.some(id => ids.includes(id)) }) } - return result + const sorted = [...result] + switch (sortBy.value) { + case 'priority-desc': + sorted.sort((a, b) => (b.goodPriority || 0) - (a.goodPriority || 0) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + break + case 'priority-asc': + sorted.sort((a, b) => (a.goodPriority || 0) - (b.goodPriority || 0) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + break + case 'name-asc': + sorted.sort((a, b) => (a.goodName || '').localeCompare(b.goodName || '', 'zh-Hans-CN')) + break + case 'name-desc': + sorted.sort((a, b) => (b.goodName || '').localeCompare(a.goodName || '', 'zh-Hans-CN')) + break + case 'created-desc': + sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + break + case 'created-asc': + sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + break + } + return sorted }) function mapCatNodes(nodes: CategoryTree[], goods: Good[]): any[] { @@ -195,22 +262,37 @@ function buildRightTree(tree: OriginGoodsTreeResponse) { goodImage: og.goodImage, goodPrice: og.goodPrice, sdsGoodId: og.sdsGoodId, + configuredCount: og.configuredCount ?? 0, + configuredCountries: og.configuredCountries ?? [], })) return { id: 'rc-' + node.categoryId, label: node.categoryName, + configuredCount: node.configuredCount ?? 0, + totalCount: node.totalCount ?? 0, children: [...children, ...goods], } } rightTreeData.value = tree.tree.map(mapCat) } -function rightFilterNode(value: string, data: any) { - if (!value) return true - if (data.isOG) return data.label.includes(value) +function rightFilterNode(_value: string, data: any) { + if (data.isOG) { + if (showUnconfiguredOnly.value && data.configuredCount > 0) return false + if (searchKeyword.value && !data.label.includes(searchKeyword.value)) return false + return true + } return true } +function toggleUnconfiguredFilter() { + showUnconfiguredOnly.value = !showUnconfiguredOnly.value + rightTreeRef.value?.filter?.('') + if (showUnconfiguredOnly.value) { + nextTick(() => setExpand(rightTreeRef.value, rightTreeData.value, true)) + } +} + // Right tree search filter watch(searchKeyword, () => { rightTreeRef.value?.filter?.('') @@ -273,24 +355,36 @@ const configOG = ref(null) const configDropTarget = ref(null) const configForm = ref({ countryId: '', cascaderCategory: [] as string[], categoryId: '', - tagIds: [] as string[], positionId: '', priority: 0, goodImage: '', + tagIds: [] as string[], positionId: '', goodImage: '', }) const configPositions = ref([]) function openConfigModal(og: any, dropTarget: any) { configOG.value = og configDropTarget.value = dropTarget - configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', priority: 0, goodImage: og.goodImage || '' } - if (mode.value === 'category') { - configForm.value.categoryId = dropTarget.id - configForm.value.cascaderCategory = findCategoryPath(allCategories.value, dropTarget.id) - } else { - configForm.value.countryId = dropTarget.id + configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' } + if (dropTarget) { + if (mode.value === 'category') { + configForm.value.categoryId = dropTarget.id + configForm.value.cascaderCategory = findCategoryPath(allCategories.value, dropTarget.id) + } else { + configForm.value.countryId = dropTarget.id + } } loadConfigPositions() configVisible.value = true } +function openConfigFromRightTree(data: any) { + openConfigModal({ + rawId: data.rawId, + goodName: data.goodName, + goodImage: data.goodImage, + goodPrice: data.goodPrice, + sdsGoodId: data.sdsGoodId, + }, null) +} + async function loadConfigPositions() { const params: any = { page: 1, pageSize: 200 } if (configForm.value.countryId) params.countryId = configForm.value.countryId @@ -319,11 +413,11 @@ async function handleConfigSubmit() { categoryId: Number(configForm.value.categoryId), tagIds: configForm.value.tagIds.map(Number), positionId: configForm.value.positionId ? Number(configForm.value.positionId) : undefined, - goodPriority: configForm.value.priority, } as any) ElMessage.success('配置成功') configVisible.value = false refreshLeftTree() + refreshRightTree() } catch (e: any) { ElMessage.error(e?.response?.data?.message || '配置失败') } finally { configLoading.value = false } @@ -335,7 +429,7 @@ const editLoading = ref(false) const editGood = ref(null) const editForm = ref({ id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[], - categoryId: '', tagIds: [] as string[], positionId: '', priority: 0, + categoryId: '', tagIds: [] as string[], positionId: '', }) function openEdit(g: Good) { @@ -347,7 +441,7 @@ function openEdit(g: Good) { cascaderCategory: findCategoryPath(allCategories.value, g.categoryId), categoryId: g.categoryId, tagIds: (g.tags || []).map(t => t.id), - positionId: g.positionId || '', priority: g.goodPriority, + positionId: g.positionId || '', } editVisible.value = true } @@ -362,11 +456,11 @@ async function handleEditSubmit() { categoryId: Number(editForm.value.categoryId), tagIds: editForm.value.tagIds.map(Number), positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null, - goodPriority: editForm.value.priority, } as any) ElMessage.success('更新成功') editVisible.value = false refreshLeftTree() + refreshRightTree() } catch (e: any) { ElMessage.error(e?.response?.data?.message || '更新失败') } finally { editLoading.value = false } @@ -386,6 +480,7 @@ async function handleDeleteGood(g: Good) { ElMessage.success('删除成功') editVisible.value = false refreshLeftTree() + refreshRightTree() } // ─── Right tree locate ─── @@ -424,6 +519,41 @@ function locateInRightTree(originGoodId: string) { }, 250) } +function locateInLeftTree(originGoodId: string) { + const good = allGoods.value.find(g => g.originGoodId === originGoodId) + if (!good) { + ElMessage.warning('该原产品尚未配置到官网') + return + } + const targetKey = 'good-' + good.id + const tree = leftTreeRef.value + if (!tree?.getNode) return + // Build the category path to expand + const catPath: string[] = [] + function findCatPath(nodes: CategoryTree[], targetCatId: string): boolean { + for (const n of nodes) { + if (n.id === targetCatId) { catPath.push(n.id); return true } + if (n.children?.length && findCatPath(n.children, targetCatId)) { + catPath.unshift(n.id); return true + } + } + return false + } + findCatPath(allCategories.value, good.categoryId) + // Expand ancestors + for (const keyId of catPath) { + const node = tree.getNode(keyId) + if (node) node.expanded = true + } + setTimeout(() => { + tree.setCurrentKey(targetKey) + nextTick(() => { + const el = document.querySelector('.gv-left .el-tree-node.is-current') as HTMLElement + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }) + }, 250) +} + // ─── Left Tree: Category CRUD ─── const catEditVisible = ref(false) const catEditMode = ref<'create' | 'edit'>('create') @@ -732,6 +862,26 @@ const tagTreeData = computed(() => { return groupNodes }) +const groupedTagOptions = computed(() => { + const groups = allTagGroups.value + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((g) => ({ + id: g.id, + label: g.groupName, + tags: allTags.value + .filter((t) => t.tagGroupId === g.id) + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)), + })) + const ungrouped = allTags.value + .filter((t) => !t.tagGroupId) + .sort((a, b) => a.tagName.localeCompare(b.tagName)) + if (ungrouped.length > 0) { + groups.push({ id: 'ungrouped', label: '未分组', tags: ungrouped }) + } + return groups +}) + function toggleTagInSelection(tagId: string): void { const idx = selectedTagIds.value.indexOf(tagId) if (idx >= 0) selectedTagIds.value.splice(idx, 1) @@ -799,8 +949,150 @@ async function quickCreateTag(targetForm: () => void) { } catch {} } +async function quickCreateTagGroup() { + try { + const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', { + confirmButtonText: '新建', cancelButtonText: '取消', inputPlaceholder: '分组名称', + }) + if (!value.trim()) return + const maxSort = Math.max(0, ...allTagGroups.value.map(g => g.sortOrder)) + await tagGroupsApi.createTagGroup({ groupName: value.trim(), sortOrder: maxSort + 1 } as any) + await reloadTags() + ElMessage.success('已创建分组') + } catch {} +} + function onModeChange() {} +// ─── Global mode: flat list with move-to-top ─── +const globalGoodsNodes = computed(() => + filteredGoods.value.map(g => goodToNode(g)) +) + +const { list: virtualList, containerProps: virtualContainer, wrapperProps: virtualWrapper } = useVirtualList( + globalGoodsNodes, + { itemHeight: 64, overscan: 5 }, +) + +const moveTopLoading = ref(null) +const globalSelectedIds = ref>(new Set()) + +// ─── Drag-to-reorder in global mode ─── +const dragGoodId = ref(null) +const dragOverGoodId = ref(null) +const dragOverPos = ref<'before' | 'after'>('before') + +function onGlobalDragStart(_e: DragEvent, goodId: string) { + dragGoodId.value = goodId +} + +function onGlobalDragOver(e: DragEvent, goodId: string) { + if (!dragGoodId.value || dragGoodId.value === goodId) return + e.preventDefault() + dragOverGoodId.value = goodId + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + dragOverPos.value = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +function onGlobalDragLeave(_e: DragEvent, goodId: string) { + if (dragOverGoodId.value === goodId) dragOverGoodId.value = null +} + +async function onGlobalDrop(_e: DragEvent) { + const draggedId = dragGoodId.value + const targetId = dragOverGoodId.value + const pos = dragOverPos.value + dragGoodId.value = null + dragOverGoodId.value = null + if (!draggedId || !targetId || draggedId === targetId) return + + // Build the new order + const nodes = [...globalGoodsNodes.value] + const fromIdx = nodes.findIndex(n => n.goodId === draggedId) + const toIdx = nodes.findIndex(n => n.goodId === targetId) + if (fromIdx === -1 || toIdx === -1) return + + const [moved] = nodes.splice(fromIdx, 1) + let insertIdx = nodes.findIndex(n => n.goodId === targetId) + if (pos === 'after') insertIdx++ + nodes.splice(insertIdx, 0, moved) + + // Recalculate priorities: first item gets highest priority + const items = nodes.map((n, i) => ({ + id: Number(n.goodId), + priority: nodes.length - i, + })) + + moveTopLoading.value = 'drag' + try { + await goodsApi.batchUpdatePriority({ items }) + await refreshLeftTree() + } catch { + ElMessage.error('排序失败') + } finally { + moveTopLoading.value = null + } +} + +function onGlobalDragEnd() { + dragGoodId.value = null + dragOverGoodId.value = null +} + +const canDragGlobal = computed(() => { + return sortBy.value === 'default' || sortBy.value === 'priority-desc' +}) + +const isAllSelected = computed(() => { + const nodes = globalGoodsNodes.value + return nodes.length > 0 && nodes.every(n => globalSelectedIds.value.has(n.goodId)) +}) +const isIndeterminate = computed(() => { + const c = globalGoodsNodes.value.filter(n => globalSelectedIds.value.has(n.goodId)).length + return c > 0 && c < globalGoodsNodes.value.length +}) + +function toggleSelectAll() { + if (isAllSelected.value) globalSelectedIds.value.clear() + else globalGoodsNodes.value.forEach(n => globalSelectedIds.value.add(n.goodId)) +} +function toggleSelectItem(goodId: string) { + if (globalSelectedIds.value.has(goodId)) globalSelectedIds.value.delete(goodId) + else globalSelectedIds.value.add(goodId) +} +function clearSelection() { globalSelectedIds.value.clear() } + +async function moveToTop(goodId: string) { + moveTopLoading.value = goodId + try { + const maxPriority = Math.max(0, ...allGoods.value.map(g => g.goodPriority || 0)) + await goodsApi.batchUpdatePriority({ items: [{ id: Number(goodId), priority: maxPriority + 1 }] }) + ElMessage.success('已置顶') + await refreshLeftTree() + } catch { ElMessage.error('操作失败') } + finally { moveTopLoading.value = null } +} + +async function batchMoveToTop() { + const ids = [...globalSelectedIds.value] + if (!ids.length) return + moveTopLoading.value = 'batch' + try { + const maxPriority = Math.max(0, ...allGoods.value.map(g => g.goodPriority || 0)) + await goodsApi.batchUpdatePriority({ + items: ids.map((id, i) => ({ id: Number(id), priority: maxPriority + ids.length - i })), + }) + ElMessage.success(`已置顶 ${ids.length} 个商品`) + clearSelection() + await refreshLeftTree() + } catch { ElMessage.error('操作失败') } + finally { moveTopLoading.value = null } +} + +function onSearch() { + rightTreeRef.value?.filter?.('') +} + onMounted(() => loadAll()) @@ -808,9 +1100,9 @@ onMounted(() => loadAll())
- - - + + + @@ -826,12 +1118,13 @@ onMounted(() => loadAll()) placement="bottom-start" :width="280" trigger="click" + transition="el-zoom-in-top" popper-class="tag-filter-popper" >
+
+ 新建分组 +
loadAll())
+ + + + 搜索 +
品类 国家 + 全局
- -
+ +
@@ -935,6 +1237,9 @@ onMounted(() => loadAll()) @dragover="onCatDragOver($event)" @drop="onCatDrop($event, data)" > + + + {{ data.label }} {{ data.goodCount }} @@ -973,10 +1278,6 @@ onMounted(() => loadAll()) >{{ t.tagName }}
-
-
优先级
-
{{ data.priority }}
-
原产品
{{ data.originGoodName }}
@@ -993,7 +1294,8 @@ onMounted(() => loadAll())
-
+
+ 下架 {{ data.country }} loadAll())
原产品库 - - {{ showAllRight ? '收起' : '展开' }} - +
+ + {{ showUnconfiguredOnly ? '✓ 仅未配置' : '仅未配置' }} + + + {{ showAllRight ? '收起' : '展开' }} + +
loadAll())
+ +
+
+ + +
+ + 展开 +
+
+
+
+
+ + +
+
+
{{ data.label }}
+
+ 下架 + {{ data.country }} + {{ t.tagName }} + +{{ data.tags!.length - 3 }} +
+
+
+ + + +
+
+
+
+
+ +
+
+
@@ -1052,7 +1463,7 @@ onMounted(() => loadAll())
-
+
@@ -1061,19 +1472,18 @@ onMounted(() => loadAll()) {{ allCountries.find(c => c.id === configForm.countryId)?.countryName }} - + {{ configDropTarget?.label }} -
- - -
+
- + + +
@@ -1083,9 +1493,6 @@ onMounted(() => loadAll())
- - -