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:
浅念
2026-09-29 01:35:09 +08:00
parent 239ef5648f
commit b3e89f7efb
5 changed files with 107 additions and 29 deletions
@@ -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(
+21 -20
View File
@@ -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