fix(admin): 广告位排序并列簇修正——create 追加 max+1/move 纯逻辑抽取/相册下拉预载
评审跟进(C1/I1/M3/M5):
- C1 排序并列簇:新建此前不送 sortOrder(服务端缺省 0)→ 3+ 广告即全 0
并列,首次上移/下移即产出错误顺序且经 outbox 立即传导 ov。修复双管:
① 新建载荷带 sortOrder=父页 max(现有)+1(空列表=0,尾部追加保证全序,
并列簇从源头不再产生——无存量数据,评审选项 B);② 上移/下移目标值
计算抽取为纯函数 planAdjacentMove(异值互换/并列错开抬邻居避免负值),
≥3 并列簇局限以 spec 文档化用例登记(create 追加后为 dead insurance),
index.vue 改用纯函数并跳过值未变侧的 no-op PATCH
- I1 相册下拉:对话框每次打开均预载选项(新建态同样,对照 SlotBindDialog
打开即 searchAlbums('') 先例),消除未输入时误显「无匹配相册」;编辑态
绑定 ID 兜底回显逻辑合并入 preloadOptions
- M3 en 复数:scopeTargeted 用 vue-i18n 复数 "{n} album | {n} albums"
(t(key, n))
- M5 预览对象 URL 兜底:onUnmounted(releasePreview) 防非常规卸载路径泄漏
已登记暂缓(非本次):M2 move 时序守卫细化、M4 相册搜索与 SlotBindDialog
的同构抽取、M6 编辑 diff-only 载荷。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>, prefix = ''): string[] =>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { fetchAlbumList } from '@/api/albums'
|
||||
@@ -129,6 +129,9 @@
|
||||
const props = defineProps<{
|
||||
/** 编辑目标(null = 新建) */
|
||||
ad: Api.Ads.Item | null
|
||||
/** 新建追加的排序权重(父页 max+1;空列表=0)——尾部追加保证全序,
|
||||
从源头杜绝并列簇 */
|
||||
nextSortOrder: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -182,6 +185,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:组件卸载时回收对象 URL(正常路径关闭对话框已回收)
|
||||
onUnmounted(releasePreview)
|
||||
|
||||
async function onFileChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -268,10 +274,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑态初始回显:已绑定 albumId 解析为标签(列表接口按 id 过滤;
|
||||
超出拉取上限或已删除的相册以裸 ID 兜底——绑定不级联清理,容失效) */
|
||||
async function hydrateBoundAlbums(ids: string[]): Promise<void> {
|
||||
/** 打开即预载选项(新建态同样预载——避免未输入时误显「无匹配相册」,
|
||||
对照 SlotBindDialog 打开即 searchAlbums('') 先例);编辑态已绑定 ID
|
||||
超出拉取上限或相册已删除的以裸 ID 兜底(绑定不级联清理,容失效) */
|
||||
async function preloadOptions(): Promise<void> {
|
||||
await searchAlbums('')
|
||||
if (props.ad === null) return
|
||||
const ids = props.ad.albumIds ?? []
|
||||
const known = new Set(options.value.map((option) => option.id))
|
||||
const missing = ids.filter((id) => !known.has(id)).map((id) => ({ id, label: id }))
|
||||
if (missing.length) options.value = [...missing, ...options.value]
|
||||
@@ -295,9 +304,7 @@
|
||||
form.value.albumIds = []
|
||||
}
|
||||
options.value = []
|
||||
if (props.ad !== null && (props.ad.albumIds ?? []).length > 0) {
|
||||
void hydrateBoundAlbums(props.ad.albumIds ?? [])
|
||||
}
|
||||
void preloadOptions()
|
||||
})
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
@@ -334,6 +341,8 @@
|
||||
imageStagingKey: staging.value!.objectKey,
|
||||
imageBytes: staging.value!.bytes,
|
||||
imageContentType: staging.value!.contentType,
|
||||
// 尾部追加(父页 max+1):sortOrder 全序,杜绝并列簇
|
||||
sortOrder: props.nextSortOrder,
|
||||
albumIds: albumIds ?? undefined
|
||||
})
|
||||
ElMessage.success(
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{{ $t('adsManage.list.scopeGlobal') }}
|
||||
</ElTag>
|
||||
<ElTag v-else type="warning" effect="plain" data-test="ad-scope-targeted">
|
||||
{{ $t('adsManage.list.scopeTargeted', { n: row.albumIds.length }) }}
|
||||
{{ $t('adsManage.list.scopeTargeted', row.albumIds.length) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
@@ -117,17 +117,22 @@
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
|
||||
<AdsFormDialog v-model="formVisible" :ad="editing" @saved="onSaved" />
|
||||
<AdsFormDialog
|
||||
v-model="formVisible"
|
||||
:ad="editing"
|
||||
:next-sort-order="nextSortOrder"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AdsFormDialog from './AdsFormDialog.vue'
|
||||
import { fetchAds, fetchDeleteAd, fetchUpdateAd } from '@/api/ads'
|
||||
import { scopeKind } from '@/utils/ads/adsLogic'
|
||||
import { planAdjacentMove, scopeKind } from '@/utils/ads/adsLogic'
|
||||
|
||||
defineOptions({ name: 'SystemAds' })
|
||||
|
||||
@@ -183,24 +188,14 @@
|
||||
if (moving.value || target < 0 || target >= items.value.length) return
|
||||
const row = items.value[index]
|
||||
const neighbor = items.value[target]
|
||||
// 互换 sortOrder;并列相等时错开一档(上移改为抬邻居 +1——契约
|
||||
// sortOrder ≥0,不可给移动行 -1),值未变的一侧跳过 no-op PATCH
|
||||
const equal = row.sortOrder === neighbor.sortOrder
|
||||
const rowNext = equal
|
||||
? dir === 1
|
||||
? neighbor.sortOrder + 1
|
||||
: neighbor.sortOrder
|
||||
: neighbor.sortOrder
|
||||
const neighborNext = equal
|
||||
? dir === -1
|
||||
? neighbor.sortOrder + 1
|
||||
: neighbor.sortOrder
|
||||
: row.sortOrder
|
||||
// 目标值计算收敛于纯函数 planAdjacentMove(异值互换/并列错开,含
|
||||
// ≥3 并列簇局限文档化;值未变的一侧跳过 no-op PATCH)
|
||||
const plan = planAdjacentMove(row.sortOrder, neighbor.sortOrder, dir)
|
||||
moving.value = true
|
||||
try {
|
||||
if (rowNext !== row.sortOrder) await fetchUpdateAd(row.id, { sortOrder: rowNext })
|
||||
if (neighborNext !== neighbor.sortOrder) {
|
||||
await fetchUpdateAd(neighbor.id, { sortOrder: neighborNext })
|
||||
if (plan.rowNext !== row.sortOrder) await fetchUpdateAd(row.id, { sortOrder: plan.rowNext })
|
||||
if (plan.neighborNext !== neighbor.sortOrder) {
|
||||
await fetchUpdateAd(neighbor.id, { sortOrder: plan.neighborNext })
|
||||
}
|
||||
await loadFirst()
|
||||
} catch {
|
||||
@@ -215,6 +210,12 @@
|
||||
const formVisible = ref(false)
|
||||
const editing = ref<Api.Ads.Item | null>(null)
|
||||
|
||||
/** 新建追加位:现有最大 sortOrder + 1(空列表=0)——尾部追加保证全序,
|
||||
从源头杜绝并列簇(planAdjacentMove 并列分支仅为兜底保险) */
|
||||
const nextSortOrder = computed(
|
||||
() => items.value.reduce((max, item) => Math.max(max, item.sortOrder), -1) + 1
|
||||
)
|
||||
|
||||
function openCreate(): void {
|
||||
editing.value = null
|
||||
formVisible.value = true
|
||||
|
||||
Reference in New Issue
Block a user