b3e89f7efb
评审跟进(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>
280 lines
9.4 KiB
Vue
280 lines
9.4 KiB
Vue
<!-- 广告位管理页(广告位轮播 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>
|