Files
photo-live/apps/admin/src/views/system/ads/index.vue
T

280 lines
9.4 KiB
Vue
Raw Normal View History

<!-- 广告位管理页(广告位轮播 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>