diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index 66be2da..353e852 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -939,7 +939,7 @@ "colEnabled": "Enabled", "colActions": "Actions", "scopeGlobal": "Global", - "scopeTargeted": "{n} albums", + "scopeTargeted": "{n} album | {n} albums", "imageAlt": "Ad image", "actionUp": "Move Up", "actionDown": "Move Down", diff --git a/apps/admin/src/utils/ads/adsLogic.spec.ts b/apps/admin/src/utils/ads/adsLogic.spec.ts index efa6c4a..c484017 100644 --- a/apps/admin/src/utils/ads/adsLogic.spec.ts +++ b/apps/admin/src/utils/ads/adsLogic.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import zhMessages from '@/locales/langs/zh.json' import enMessages from '@/locales/langs/en.json' -import { isValidAdLinkUrl, normalizeAlbumIds, scopeKind } from './adsLogic' +import { isValidAdLinkUrl, normalizeAlbumIds, planAdjacentMove, scopeKind } from './adsLogic' describe('scopeKind 投放范围分类', () => { it('null / 空数组 → global;非空 → targeted', () => { @@ -44,6 +44,46 @@ describe('isValidAdLinkUrl 表单链接校验(与服务端 pattern 同源)', }) }) +describe('planAdjacentMove 上移/下移目标值计算', () => { + it('异值互换:双方互换 sortOrder(上移/下移同式)', () => { + expect(planAdjacentMove(5, 8, -1)).toEqual({ rowNext: 8, neighborNext: 5 }) + expect(planAdjacentMove(5, 8, 1)).toEqual({ rowNext: 8, neighborNext: 5 }) + expect(planAdjacentMove(0, 3, -1)).toEqual({ rowNext: 3, neighborNext: 0 }) + }) + + it('并列 2 行:下移抬本行 +1、上移抬邻居 +1(sortOrder ≥0 契约),另一侧保持', () => { + expect(planAdjacentMove(0, 0, 1)).toEqual({ rowNext: 1, neighborNext: 0 }) + expect(planAdjacentMove(0, 0, -1)).toEqual({ rowNext: 0, neighborNext: 1 }) + expect(planAdjacentMove(3, 3, 1)).toEqual({ rowNext: 4, neighborNext: 3 }) + expect(planAdjacentMove(3, 3, -1)).toEqual({ rowNext: 3, neighborNext: 4 }) + }) + + it('已知局限(文档化):并列簇 ≥3 时单对错开不完备——B 上移后第三方 C 仍并列压过被抬的 A', () => { + // [A,B,C] 全 sortOrder=0(id 序=createdAt 序代理);B 上移 → A=1、B=0、C=0 + // → 排序结果 B,C,A(期望 B,A,C):C 越过 A。新建恒为尾部追加(父页传 + // max+1)后线上不会出现并列簇,此分支为历史数据兜底保险(dead insurance)。 + const plan = planAdjacentMove(0, 0, -1) // row=B, neighbor=A + expect(plan).toEqual({ rowNext: 0, neighborNext: 1 }) + const after = [ + { id: 'A', sortOrder: plan.neighborNext }, + { id: 'B', sortOrder: plan.rowNext }, + { id: 'C', sortOrder: 0 } + ] + const sorted = [...after].sort((a, b) => a.sortOrder - b.sortOrder || a.id.localeCompare(b.id)) + expect(sorted.map((row) => row.id)).toEqual(['B', 'C', 'A']) + }) + + it('边界 no-op 判定:异值互换双方均变更;并列恰一侧变更(调用方据此跳过 no-op PATCH)', () => { + const swap = planAdjacentMove(2, 7, 1) + expect(swap.rowNext).not.toBe(2) + expect(swap.neighborNext).not.toBe(7) + const bumpDown = planAdjacentMove(7, 7, 1) + expect([bumpDown.rowNext !== 7, bumpDown.neighborNext !== 7].filter(Boolean)).toHaveLength(1) + const bumpUp = planAdjacentMove(7, 7, -1) + expect([bumpUp.rowNext !== 7, bumpUp.neighborNext !== 7].filter(Boolean)).toHaveLength(1) + }) +}) + describe('adsManage 词条 zh/en 键集一致(对照 embedSlotsManage 先例)', () => { it('zh/en 键集合一致(adsManage 子树)', () => { const keysOf = (obj: Record, prefix = ''): string[] => diff --git a/apps/admin/src/utils/ads/adsLogic.ts b/apps/admin/src/utils/ads/adsLogic.ts index ac42dda..b706b51 100644 --- a/apps/admin/src/utils/ads/adsLogic.ts +++ b/apps/admin/src/utils/ads/adsLogic.ts @@ -5,6 +5,9 @@ * 空数组提交落库归一 null(AdCreateRequestDto 注释口径) * - isValidAdLinkUrl 与服务端 pattern 同源(dto.ts LINK_URL_PATTERN: * ^https?:// 前缀 + 契约 maxLength 2048) + * - planAdjacentMove 上移/下移目标值计算:新建恒为尾部追加(父页传 + * max+1,空列表=0),线上 sortOrder 全序无并列;并列分支为历史数据 + * 兜底保险(≥3 并列簇的局限见 spec 文档化用例) */ /** 投放范围:null 或空数组 → 'global';非空 → 'targeted'。 */ @@ -23,3 +26,28 @@ export function normalizeAlbumIds(v: string[] | null): string[] | null { export function isValidAdLinkUrl(u: string): boolean { return /^https?:\/\//.test(u) && u.length <= 2048 } + +/** 上移/下移的目标 sortOrder(值未变的一侧调用方跳过 no-op PATCH) */ +export interface AdjacentMovePlan { + rowNext: number + neighborNext: number +} + +/** + * 邻位移动目标值:异值直接互换;并列(相等)时错开一档——下移抬本行 + * +1、上移抬邻居 +1(契约 sortOrder ≥0,不可产出 -1),另一侧保持原值。 + * 已知局限:并列簇 ≥3 时单对错开不完备(第三方仍并列压位,见 spec + * 文档化用例);新建恒为尾部追加(max+1)后并列不会在线上出现。 + */ +export function planAdjacentMove( + rowSortOrder: number, + neighborSortOrder: number, + dir: -1 | 1 +): AdjacentMovePlan { + if (rowSortOrder !== neighborSortOrder) { + return { rowNext: neighborSortOrder, neighborNext: rowSortOrder } + } + return dir === 1 + ? { rowNext: neighborSortOrder + 1, neighborNext: neighborSortOrder } + : { rowNext: rowSortOrder, neighborNext: neighborSortOrder + 1 } +} diff --git a/apps/admin/src/views/system/ads/AdsFormDialog.vue b/apps/admin/src/views/system/ads/AdsFormDialog.vue index 4981f02..1bbff33 100644 --- a/apps/admin/src/views/system/ads/AdsFormDialog.vue +++ b/apps/admin/src/views/system/ads/AdsFormDialog.vue @@ -111,7 +111,7 @@