Files
photo-live/apps/admin/src/views/system/ads/index.vue
T
浅念 b3e89f7efb 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>
2026-09-29 01:35:09 +08:00

280 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 广告位管理页(广告位轮播 Task 11):全量列表(缩略图/备注名/跳转链接/
投放范围/启停开关)+ 新建/编辑对话框 + 上移/下移排序 + 硬删除。列表按
sortOrder 升序返回,imageUrl 为现签 GET——过期后重拉本列表刷新。启停/
排序/删除均直接变更观众端可见内容(ov 投影实时跟随)。 -->
<template>
<div class="ui06-page">
<!-- 页头(示例 13 .page-heading) -->
<div class="ui06-page-heading">
<div>
<h1>{{ $t('adsManage.list.title') }}</h1>
<p>{{ $t('adsManage.list.subtitle') }}</p>
</div>
<div class="actions">
<ElButton type="primary" data-test="ad-create-btn" @click="openCreate">
{{ $t('adsManage.list.create') }}
</ElButton>
</div>
</div>
<ElCard data-test="ads-card">
<template #header>
<div class="flex items-center justify-between">
<span class="ui06-card-title">{{ $t('adsManage.list.tableTitle') }}</span>
</div>
</template>
<ElAlert v-if="error && !items.length" type="error" :closable="false" data-test="ads-error">
<div class="flex items-center gap-2">
<span>{{ $t('adsManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="ads-retry" @click="loadFirst()">
{{ $t('adsManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="ads-table">
<ElTableColumn :label="$t('adsManage.list.colImage')" width="130">
<template #default="{ row }">
<img
:src="row.imageUrl"
:alt="$t('adsManage.list.imageAlt')"
class="ads-thumb"
data-test="ad-image"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colTitle')" min-width="150">
<template #default="{ row }">
<span class="font-medium" data-test="ad-title">{{ row.title ?? '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colLink')" min-width="220">
<template #default="{ row }">
<span class="ads-link" :title="row.linkUrl" data-test="ad-link">
{{ row.linkUrl }}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colScope')" width="130">
<template #default="{ row }">
<ElTag
v-if="scopeKind(row.albumIds) === 'global'"
type="success"
effect="plain"
data-test="ad-scope-global"
>
{{ $t('adsManage.list.scopeGlobal') }}
</ElTag>
<ElTag v-else type="warning" effect="plain" data-test="ad-scope-targeted">
{{ $t('adsManage.list.scopeTargeted', row.albumIds.length) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colEnabled')" width="90">
<template #default="{ row }">
<ElSwitch
:model-value="row.enabled"
:loading="togglingId === row.id"
:disabled="togglingId !== null && togglingId !== row.id"
data-test="ad-enabled-switch"
@change="toggleEnabled(row as Api.Ads.Item)"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colActions')" width="260" fixed="right">
<template #default="{ row, $index }">
<ElButton
link
type="primary"
:disabled="$index === 0 || moving"
data-test="ad-up-btn"
@click="move($index, -1)"
>
{{ $t('adsManage.list.actionUp') }}
</ElButton>
<ElButton
link
type="primary"
:disabled="$index === items.length - 1 || moving"
data-test="ad-down-btn"
@click="move($index, 1)"
>
{{ $t('adsManage.list.actionDown') }}
</ElButton>
<ElButton link type="primary" data-test="ad-edit-btn" @click="openEdit(row)">
{{ $t('adsManage.list.actionEdit') }}
</ElButton>
<ElButton link type="danger" data-test="ad-delete-btn" @click="remove(row)">
{{ $t('adsManage.list.actionDelete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('adsManage.list.empty')" />
</template>
</ElTable>
</ElCard>
<AdsFormDialog
v-model="formVisible"
:ad="editing"
:next-sort-order="nextSortOrder"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
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 { planAdjacentMove, scopeKind } from '@/utils/ads/adsLogic'
defineOptions({ name: 'SystemAds' })
const { t } = useI18n()
// ── 列表:契约全量不分页(sortOrder 升序),整页替换式加载 ──
const items = ref<Api.Ads.Item[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
async function loadFirst(): Promise<void> {
loading.value = true
error.value = null
try {
const res = await fetchAds()
items.value = res.items
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// ── 启停开关:PATCH enabled(false 公开端不可见)──
const togglingId = ref<string | null>(null)
async function toggleEnabled(row: Api.Ads.Item): Promise<void> {
togglingId.value = row.id
try {
const updated = await fetchUpdateAd(row.id, { enabled: !row.enabled })
replaceById(updated)
ElMessage.success(
updated.enabled ? t('adsManage.toast.enabledOn') : t('adsManage.toast.enabledOff')
)
} catch {
// 失败回滚+重拉:ElSwitch 单向绑定行数据未变即回弹,重拉对齐服务端状态
await loadFirst()
} finally {
togglingId.value = null
}
}
function replaceById(next: Api.Ads.Item): void {
const index = items.value.findIndex((item) => item.id === next.id)
if (index >= 0) items.value.splice(index, 1, next)
}
// ── 上移/下移:与相邻行互换 sortOrder(两次 PATCH 后重拉列表对齐回显)──
const moving = ref(false)
async function move(index: number, dir: -1 | 1): Promise<void> {
const target = index + dir
if (moving.value || target < 0 || target >= items.value.length) return
const row = items.value[index]
const neighbor = items.value[target]
// 目标值计算收敛于纯函数 planAdjacentMove(异值互换/并列错开,含
// ≥3 并列簇局限文档化;值未变的一侧跳过 no-op PATCH)
const plan = planAdjacentMove(row.sortOrder, neighbor.sortOrder, dir)
moving.value = true
try {
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 {
// 错误已由 http 层统一弹出;重拉恢复服务端真实顺序
await loadFirst()
} finally {
moving.value = false
}
}
// ── 新建 / 编辑 ──
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
}
function openEdit(row: Api.Ads.Item): void {
editing.value = row
formVisible.value = true
}
/** 新建/编辑成功 → 重拉(排序位置与现签 imageUrl 一并对齐) */
function onSaved(): void {
void loadFirst()
}
/** 硬删除:确认(数据不保留、不可恢复)→ DELETE → 重拉列表 */
async function remove(row: Api.Ads.Item): Promise<void> {
try {
await ElMessageBox.confirm(
t('adsManage.list.deleteConfirm', { name: row.title ?? row.id }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAd(row.id)
ElMessage.success(t('adsManage.list.deleted'))
loadFirst()
}
onMounted(() => {
void loadFirst()
})
</script>
<style scoped>
.ads-thumb {
width: 96px;
height: 48px;
object-fit: cover;
display: block;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
.ads-link {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
color: var(--el-text-color-regular);
}
</style>