feat(admin): 广告位管理页——上传/链接/绑定相册/开关/排序
Task 11(广告位轮播):admin 后台广告位治理页。 - utils/ads/adsLogic:scopeKind/normalizeAlbumIds/isValidAdLinkUrl 纯逻辑 (与服务端 dto LINK_URL_PATTERN、albumIds 归一语义同源)+ vitest 6 例 - api/ads + types/api/ads.d.ts:list/create/update/delete/initUpload 五端点 (对照 embedSlots.ts 逐字风格;列表全量不分页、imageUrl 现签) - views/system/ads:列表(缩略图/备注名/链接截断/投放范围标签/启停开关 失败重拉回滚/上移下移 sortOrder 互换/硬删确认)+ 新建编辑对话框 (staging 直传 init→裸 fetch PUT、Content-Type 与申明一致;相册多选 远程搜索复用 /v1/albums 分页+本地过滤,绑定 ID 超限/失效以裸 ID 兜底; 编辑换图才带 image 三字段;albumIds 归一空→null 转全局) - 路由/菜单:system 组 ads 页(ri:advertisement-line,workspace 分组); locales zh/en 补 menus.system.ads + adsManage 子树(键集一致入单测) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
<!-- 广告位新建/编辑对话框(广告位轮播 Task 11):广告图直传(init 签发
|
||||
staging 凭证 → 裸 fetch PUT 预签名 URL,Content-Type 与申明一致——无
|
||||
complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl
|
||||
与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索,
|
||||
/v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image
|
||||
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局)。 -->
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="ad === null ? $t('adsManage.create.title') : $t('adsManage.edit.title')"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
data-test="ad-form-dialog"
|
||||
>
|
||||
<ElForm ref="formRef" label-width="110px" :model="form" :rules="rules">
|
||||
<ElFormItem :label="$t('adsManage.form.imageLabel')">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElButton :loading="uploading" data-test="ad-form-pick" @click="fileInput?.click()">
|
||||
{{
|
||||
staging || ad !== null
|
||||
? $t('adsManage.form.imageRepick')
|
||||
: $t('adsManage.form.imagePick')
|
||||
}}
|
||||
</ElButton>
|
||||
<span class="text-gray-400 text-sm">{{ $t('adsManage.form.imageHint') }}</span>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/heic"
|
||||
class="hidden"
|
||||
data-test="ad-form-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
<!-- 预览:新选文件用本地对象 URL;未换图的编辑态沿用行内现签 URL -->
|
||||
<div v-if="previewSrc" class="flex items-center gap-2" data-test="ad-form-preview">
|
||||
<img
|
||||
:src="previewSrc"
|
||||
:alt="$t('adsManage.list.imageAlt')"
|
||||
class="ads-form-preview-img"
|
||||
data-test="ad-form-preview-img"
|
||||
/>
|
||||
<ElTag v-if="staging" type="warning" effect="light" data-test="ad-form-staged-tag">
|
||||
{{ $t('adsManage.form.imageStagedTag') }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div v-if="uploadError" class="text-red-500 text-sm" data-test="ad-form-upload-error">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('adsManage.form.linkLabel')" prop="linkUrl">
|
||||
<ElInput
|
||||
v-model="form.linkUrl"
|
||||
:placeholder="$t('adsManage.form.linkPlaceholder')"
|
||||
maxlength="2048"
|
||||
data-test="ad-form-link"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('adsManage.form.titleLabel')">
|
||||
<ElInput
|
||||
v-model="form.title"
|
||||
:placeholder="$t('adsManage.form.titlePlaceholder')"
|
||||
maxlength="120"
|
||||
show-word-limit
|
||||
data-test="ad-form-title"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('adsManage.form.albumsLabel')">
|
||||
<ElSelect
|
||||
v-model="form.albumIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="searchAlbums"
|
||||
:loading="searching"
|
||||
:placeholder="$t('adsManage.form.albumsPlaceholder')"
|
||||
style="width: 100%"
|
||||
data-test="ad-form-albums"
|
||||
>
|
||||
<ElOption
|
||||
v-for="album in options"
|
||||
:key="album.id"
|
||||
:value="album.id"
|
||||
:label="album.label"
|
||||
:data-test="`ad-form-album-option-${album.id}`"
|
||||
/>
|
||||
<template #empty>
|
||||
<span class="px-4 py-2 text-gray-400 text-sm" data-test="ad-form-albums-empty">
|
||||
{{
|
||||
searching ? t('adsManage.form.albumsSearching') : t('adsManage.form.albumsNoMatch')
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</ElSelect>
|
||||
<div class="text-gray-400 text-xs mt-1">{{ $t('adsManage.form.albumsHint') }}</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton data-test="ad-form-cancel" @click="visible = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" data-test="ad-form-submit" @click="submit">
|
||||
{{ $t('adsManage.form.submit') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { fetchAlbumList } from '@/api/albums'
|
||||
import { fetchCreateAd, fetchInitAdUpload, fetchUpdateAd } from '@/api/ads'
|
||||
import { isValidAdLinkUrl, normalizeAlbumIds } from '@/utils/ads/adsLogic'
|
||||
|
||||
/** 广告图字节上限(契约 AD_UPLOAD_MAX_BYTES=2MB,独立于照片域 50MB) */
|
||||
const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/heic'])
|
||||
|
||||
/** 相册搜索拉取页数上限(每页 50:列表端点无标题过滤,本地匹配;管理
|
||||
规模有界,5 页 ≈ 250 本封顶,超出以 ID 兜底标签回显) */
|
||||
const MAX_SEARCH_PAGES = 5
|
||||
|
||||
const props = defineProps<{
|
||||
/** 编辑目标(null = 新建) */
|
||||
ad: Api.Ads.Item | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 新建/编辑成功(父页重拉列表对齐排序与现签 imageUrl) */
|
||||
saved: [ad: Api.Ads.Item]
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
const { t } = useI18n()
|
||||
|
||||
const formRef = ref<FormInstance | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
linkUrl: '',
|
||||
title: '',
|
||||
albumIds: [] as string[]
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
linkUrl: [
|
||||
{ required: true, message: t('adsManage.form.linkRequired'), trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
callback(isValidAdLinkUrl(value) ? undefined : new Error(t('adsManage.form.linkInvalid')))
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
// ── 广告图直传:init → 裸 fetch PUT staging(无 complete;创建/更新核验)──
|
||||
interface StagingImage {
|
||||
objectKey: string
|
||||
bytes: number
|
||||
contentType: Api.Ads.ImageContentType
|
||||
}
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false)
|
||||
const uploadError = ref('')
|
||||
const staging = ref<StagingImage | null>(null)
|
||||
/** 新选文件预览(本地对象 URL;替换时回收,对话框重开时回收) */
|
||||
const previewObjectUrl = ref<string | null>(null)
|
||||
const previewSrc = computed(() => previewObjectUrl.value ?? props.ad?.imageUrl ?? null)
|
||||
|
||||
function releasePreview(): void {
|
||||
if (previewObjectUrl.value !== null) {
|
||||
URL.revokeObjectURL(previewObjectUrl.value)
|
||||
previewObjectUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// 允许同一文件连续重选:change 处理完即复位
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!CONTENT_TYPES.has(file.type)) {
|
||||
uploadError.value = t('adsManage.form.imageInvalidType')
|
||||
return
|
||||
}
|
||||
if (file.size > AD_UPLOAD_MAX_BYTES) {
|
||||
uploadError.value = t('adsManage.form.imageTooLarge')
|
||||
return
|
||||
}
|
||||
uploadError.value = ''
|
||||
uploading.value = true
|
||||
try {
|
||||
// ① init:staging 键 + 预签名凭证(失败重试重新 init,staging 键不复用)
|
||||
const init = await fetchInitAdUpload({
|
||||
filename: file.name,
|
||||
bytes: file.size,
|
||||
contentType: file.type as Api.Ads.ImageContentType
|
||||
})
|
||||
// ② PUT staging:裸 fetch(预签名 URL 自带凭证;headers 必须原样携带)
|
||||
const response = await fetch(init.credential.url, {
|
||||
method: init.credential.method,
|
||||
headers: init.credential.headers,
|
||||
body: file
|
||||
})
|
||||
if (!response.ok) throw new Error(`PUT ${response.status}`)
|
||||
staging.value = {
|
||||
objectKey: init.objectKey,
|
||||
bytes: file.size,
|
||||
contentType: file.type as Api.Ads.ImageContentType
|
||||
}
|
||||
releasePreview()
|
||||
previewObjectUrl.value = URL.createObjectURL(file)
|
||||
} catch (e) {
|
||||
uploadError.value = t('adsManage.form.imageUploadFailed', { message: (e as Error).message })
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 绑定相册:多选远程搜索(/v1/albums 分页拉取 + 本地按标题/ID 过滤)──
|
||||
interface AlbumOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const options = ref<AlbumOption[]>([])
|
||||
const searching = ref(false)
|
||||
|
||||
/** 列表标题按 defaultLocale 解析(回退第一条翻译,同相册管理页) */
|
||||
function titleOf(album: Api.Album.Summary): string {
|
||||
const preferred = album.translations.find((tr) => tr.locale === album.defaultLocale)
|
||||
return preferred?.title ?? album.translations[0]?.title ?? album.id
|
||||
}
|
||||
|
||||
async function searchAlbums(query: string): Promise<void> {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
searching.value = true
|
||||
try {
|
||||
const all: Api.Album.Summary[] = []
|
||||
let cursor: string | undefined
|
||||
for (let page = 0; page < MAX_SEARCH_PAGES; page++) {
|
||||
const res = await fetchAlbumList({ cursor, limit: 50 })
|
||||
all.push(...res.items)
|
||||
if (!res.hasMore || res.nextCursor === null) break
|
||||
cursor = res.nextCursor
|
||||
}
|
||||
options.value =
|
||||
keyword === ''
|
||||
? all.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
|
||||
: all
|
||||
.filter(
|
||||
(album) =>
|
||||
titleOf(album).toLowerCase().includes(keyword) ||
|
||||
album.id.toLowerCase().includes(keyword)
|
||||
)
|
||||
.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑态初始回显:已绑定 albumId 解析为标签(列表接口按 id 过滤;
|
||||
超出拉取上限或已删除的相册以裸 ID 兜底——绑定不级联清理,容失效) */
|
||||
async function hydrateBoundAlbums(ids: string[]): Promise<void> {
|
||||
await searchAlbums('')
|
||||
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]
|
||||
}
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (!open) {
|
||||
releasePreview()
|
||||
return
|
||||
}
|
||||
uploadError.value = ''
|
||||
staging.value = null
|
||||
releasePreview()
|
||||
if (props.ad !== null) {
|
||||
form.value.linkUrl = props.ad.linkUrl
|
||||
form.value.title = props.ad.title ?? ''
|
||||
form.value.albumIds = props.ad.albumIds ?? []
|
||||
} else {
|
||||
form.value.linkUrl = ''
|
||||
form.value.title = ''
|
||||
form.value.albumIds = []
|
||||
}
|
||||
options.value = []
|
||||
if (props.ad !== null && (props.ad.albumIds ?? []).length > 0) {
|
||||
void hydrateBoundAlbums(props.ad.albumIds ?? [])
|
||||
}
|
||||
})
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (valid === false) return
|
||||
// 新建必须先传图;编辑态未换图沿用原图(不带 image 三字段)
|
||||
if (props.ad === null && staging.value === null) {
|
||||
uploadError.value = t('adsManage.form.imageRequired')
|
||||
return
|
||||
}
|
||||
uploadError.value = ''
|
||||
const title = form.value.title.trim()
|
||||
const albumIds = normalizeAlbumIds(form.value.albumIds)
|
||||
submitting.value = true
|
||||
try {
|
||||
const saved =
|
||||
props.ad !== null
|
||||
? await fetchUpdateAd(props.ad.id, {
|
||||
// title 服务端 @IsNotEmpty:空串不可提交,清空=保持原值
|
||||
...(title === '' ? {} : { title }),
|
||||
linkUrl: form.value.linkUrl.trim(),
|
||||
albumIds,
|
||||
...(staging.value !== null
|
||||
? {
|
||||
imageStagingKey: staging.value.objectKey,
|
||||
imageBytes: staging.value.bytes,
|
||||
imageContentType: staging.value.contentType
|
||||
}
|
||||
: {})
|
||||
})
|
||||
: await fetchCreateAd({
|
||||
...(title === '' ? {} : { title }),
|
||||
linkUrl: form.value.linkUrl.trim(),
|
||||
imageStagingKey: staging.value!.objectKey,
|
||||
imageBytes: staging.value!.bytes,
|
||||
imageContentType: staging.value!.contentType,
|
||||
albumIds: albumIds ?? undefined
|
||||
})
|
||||
ElMessage.success(
|
||||
props.ad !== null ? t('adsManage.edit.saved') : t('adsManage.create.created')
|
||||
)
|
||||
visible.value = false
|
||||
emit('saved', saved)
|
||||
} catch {
|
||||
// 错误已由 http 层统一弹出(ElMessage),此处仅吸收避免未处理拒绝
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ads-form-preview-img {
|
||||
width: 160px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- 广告位管理页(广告位轮播 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', { n: 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" @saved="onSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { 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'
|
||||
|
||||
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]
|
||||
// 互换 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
|
||||
moving.value = true
|
||||
try {
|
||||
if (rowNext !== row.sortOrder) await fetchUpdateAd(row.id, { sortOrder: rowNext })
|
||||
if (neighborNext !== neighbor.sortOrder) {
|
||||
await fetchUpdateAd(neighbor.id, { sortOrder: neighborNext })
|
||||
}
|
||||
await loadFirst()
|
||||
} catch {
|
||||
// 错误已由 http 层统一弹出;重拉恢复服务端真实顺序
|
||||
await loadFirst()
|
||||
} finally {
|
||||
moving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 新建 / 编辑 ──
|
||||
const formVisible = ref(false)
|
||||
const editing = ref<Api.Ads.Item | null>(null)
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user