You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
663 lines
20 KiB
663 lines
20 KiB
<script setup lang="ts">
|
|
usePageTitle('媒体库')
|
|
|
|
type RefContext = {
|
|
ownerType: string
|
|
ownerId: number
|
|
label: string
|
|
href: string | null
|
|
}
|
|
|
|
type MediaAssetRow = {
|
|
id: number
|
|
storageKey: string
|
|
publicPath: string
|
|
mime: string
|
|
sizeBytes: number
|
|
createdAt: string
|
|
refCount: number
|
|
userNote: string | null
|
|
refContexts: RefContext[]
|
|
canDelete: boolean
|
|
deleteBlockedReason: 'referenced' | 'cooling' | null
|
|
deleteGraceExpiresAt: string | null
|
|
}
|
|
|
|
const toast = useToast()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const { fetchData, getApiErrorMessage } = useClientApi()
|
|
|
|
/** 与 `NUXT_PUBLIC_SITE_URL` / `runtimeConfig.public.siteUrl` 一致;未配置则退回当前页 origin。 */
|
|
function resolveCopyOrigin(): string {
|
|
const raw = String(runtimeConfig.public.siteUrl ?? '').trim()
|
|
if (raw) {
|
|
try {
|
|
return new URL(raw).origin
|
|
} catch {
|
|
/* 使用 window */
|
|
}
|
|
}
|
|
return typeof window !== 'undefined' ? window.location.origin : ''
|
|
}
|
|
|
|
const page = ref(1)
|
|
const pageSize = ref(20)
|
|
const cardDensity = ref<'compact' | 'detailed'>('compact')
|
|
const items = ref<MediaAssetRow[]>([])
|
|
const total = ref(0)
|
|
const loading = ref(true)
|
|
/** 非首屏刷新(翻页、搜索):列表区轻量遮罩,避免旧数据与新结果切换生硬 */
|
|
const listBusy = ref(false)
|
|
/** 输入框内容;`appliedSearch` 防抖后参与请求 */
|
|
const searchInput = ref('')
|
|
const appliedSearch = ref('')
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
const uploading = ref(false)
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const reuploadInput = ref<HTMLInputElement | null>(null)
|
|
const reuploadTargetId = ref<number | null>(null)
|
|
const reuploadingId = ref<number | null>(null)
|
|
/** 预览加载失败(多为磁盘缺文件) */
|
|
const previewFailedIds = ref<Set<number>>(new Set())
|
|
const imgBust = ref<Record<number, number>>({})
|
|
const noteDraft = reactive<Record<number, string>>({})
|
|
const savingNoteId = ref<number | null>(null)
|
|
/** 正在编辑描述的资源 id;其余项用服务端文案展示 */
|
|
const editingNoteId = ref<number | null>(null)
|
|
/** 引用详情是否展开(按资源 id) */
|
|
const refDetailOpen = reactive<Record<number, boolean>>({})
|
|
const deleteConfirmOpen = ref(false)
|
|
const pendingDeleteAsset = ref<MediaAssetRow | null>(null)
|
|
const deletingId = ref<number | null>(null)
|
|
|
|
const skeletonPlaceholders = computed(() => Math.min(Math.max(pageSize.value, 1), 12))
|
|
|
|
watch(
|
|
items,
|
|
(list) => {
|
|
for (const it of list) {
|
|
if (editingNoteId.value === it.id) {
|
|
continue
|
|
}
|
|
noteDraft[it.id] = it.userNote ?? ''
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
const pageSizeItems = [
|
|
{ label: '每页 10', value: 10 },
|
|
{ label: '每页 20', value: 20 },
|
|
{ label: '每页 50', value: 50 },
|
|
]
|
|
|
|
const isCompact = computed(() => cardDensity.value === 'compact')
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) {
|
|
return `${n} B`
|
|
}
|
|
if (n < 1024 * 1024) {
|
|
return `${(n / 1024).toFixed(1)} KB`
|
|
}
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
function formatDt(iso: string | null): string {
|
|
if (!iso) {
|
|
return '—'
|
|
}
|
|
const d = new Date(iso)
|
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN')
|
|
}
|
|
|
|
function deleteDisabledHint(item: MediaAssetRow): string {
|
|
if (item.canDelete) {
|
|
return ''
|
|
}
|
|
if (item.deleteBlockedReason === 'referenced') {
|
|
return '该文件仍被文章或资料引用,请先从正文、封面或资料中移除对应图片后再删除。'
|
|
}
|
|
if (item.deleteGraceExpiresAt) {
|
|
return `宽限期未满,预计 ${formatDt(item.deleteGraceExpiresAt)} 后可删除;也可在「媒体 → 孤儿清理」中查看进度。`
|
|
}
|
|
return '当前不可删除,请稍后在「孤儿清理」中处理。'
|
|
}
|
|
|
|
function openDeleteConfirm(item: MediaAssetRow) {
|
|
if (!item.canDelete) {
|
|
return
|
|
}
|
|
pendingDeleteAsset.value = item
|
|
deleteConfirmOpen.value = true
|
|
}
|
|
|
|
function closeDeleteModal() {
|
|
deleteConfirmOpen.value = false
|
|
}
|
|
|
|
watch(deleteConfirmOpen, (open) => {
|
|
if (!open) {
|
|
pendingDeleteAsset.value = null
|
|
}
|
|
})
|
|
|
|
async function executeDelete() {
|
|
const item = pendingDeleteAsset.value
|
|
if (!item) {
|
|
return
|
|
}
|
|
deletingId.value = item.id
|
|
try {
|
|
await fetchData(`/api/me/media/assets/${item.id}`, {
|
|
method: 'DELETE',
|
|
notify: false,
|
|
})
|
|
toast.add({ title: '已删除', color: 'success' })
|
|
closeDeleteModal()
|
|
if (editingNoteId.value === item.id) {
|
|
editingNoteId.value = null
|
|
}
|
|
delete refDetailOpen[item.id]
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
deletingId.value = null
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
const searchTrim = appliedSearch.value.trim()
|
|
const blocking = items.value.length === 0 && !searchTrim
|
|
if (blocking) {
|
|
loading.value = true
|
|
} else {
|
|
listBusy.value = true
|
|
}
|
|
try {
|
|
const q = new URLSearchParams({
|
|
page: String(page.value),
|
|
pageSize: String(pageSize.value),
|
|
})
|
|
if (searchTrim) {
|
|
q.set('search', searchTrim)
|
|
}
|
|
const body = await fetchData<{ items: MediaAssetRow[]; total: number }>(
|
|
`/api/me/media/assets?${q.toString()}`,
|
|
{ notify: false },
|
|
)
|
|
items.value = body.items
|
|
total.value = body.total
|
|
const ids = new Set(body.items.map((i) => i.id))
|
|
previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => ids.has(id)))
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
if (blocking) {
|
|
loading.value = false
|
|
} else {
|
|
listBusy.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
watch(pageSize, () => {
|
|
page.value = 1
|
|
})
|
|
|
|
watch(searchInput, () => {
|
|
if (searchDebounceTimer) {
|
|
clearTimeout(searchDebounceTimer)
|
|
}
|
|
searchDebounceTimer = setTimeout(() => {
|
|
searchDebounceTimer = null
|
|
const next = searchInput.value.trim()
|
|
if (next === appliedSearch.value) {
|
|
return
|
|
}
|
|
if (page.value !== 1) {
|
|
page.value = 1
|
|
}
|
|
appliedSearch.value = next
|
|
}, 350)
|
|
})
|
|
|
|
watch([page, pageSize, appliedSearch], () => {
|
|
void load()
|
|
})
|
|
|
|
onMounted(() => {
|
|
void load()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (searchDebounceTimer) {
|
|
clearTimeout(searchDebounceTimer)
|
|
searchDebounceTimer = null
|
|
}
|
|
})
|
|
|
|
function openFilePicker() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function onFileChange(ev: Event) {
|
|
const input = ev.target as HTMLInputElement
|
|
const files = input.files ? Array.from(input.files) : []
|
|
input.value = ''
|
|
if (!files.length) {
|
|
return
|
|
}
|
|
|
|
uploading.value = true
|
|
try {
|
|
const form = new FormData()
|
|
for (const f of files) {
|
|
form.append('file', f)
|
|
}
|
|
await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
|
|
method: 'POST',
|
|
body: form,
|
|
notify: false,
|
|
})
|
|
toast.add({
|
|
title: files.length === 1 ? '上传成功' : `已上传 ${files.length} 个文件`,
|
|
color: 'success',
|
|
})
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
uploading.value = false
|
|
}
|
|
}
|
|
|
|
async function copyUrl(item: MediaAssetRow) {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
|
|
try {
|
|
await navigator.clipboard.writeText(absoluteUrl)
|
|
toast.add({ title: '已复制 URL', color: 'success' })
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
}
|
|
}
|
|
|
|
function imageSrc(item: MediaAssetRow): string {
|
|
const b = imgBust.value[item.id]
|
|
return b ? `${item.publicPath}?v=${b}` : item.publicPath
|
|
}
|
|
|
|
function onImgError(id: number) {
|
|
previewFailedIds.value = new Set([...previewFailedIds.value, id])
|
|
}
|
|
|
|
function openReuploadPicker(assetId: number) {
|
|
reuploadTargetId.value = assetId
|
|
nextTick(() => reuploadInput.value?.click())
|
|
}
|
|
|
|
function startEditNote(item: MediaAssetRow) {
|
|
editingNoteId.value = item.id
|
|
noteDraft[item.id] = item.userNote ?? ''
|
|
}
|
|
|
|
function cancelEditNote(item: MediaAssetRow) {
|
|
editingNoteId.value = null
|
|
noteDraft[item.id] = item.userNote ?? ''
|
|
}
|
|
|
|
async function saveMyNote(assetId: number) {
|
|
savingNoteId.value = assetId
|
|
try {
|
|
await fetchData(`/api/me/media/assets/${assetId}/note`, {
|
|
method: 'PATCH',
|
|
body: { userNote: noteDraft[assetId] ?? '' },
|
|
notify: false,
|
|
})
|
|
toast.add({ title: '描述已保存', color: 'success' })
|
|
editingNoteId.value = null
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
savingNoteId.value = null
|
|
}
|
|
}
|
|
|
|
function toggleRefDetail(id: number) {
|
|
refDetailOpen[id] = !refDetailOpen[id]
|
|
}
|
|
|
|
async function onReuploadFile(ev: Event) {
|
|
const assetId = reuploadTargetId.value
|
|
reuploadTargetId.value = null
|
|
const input = ev.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
input.value = ''
|
|
if (!assetId || !file) {
|
|
return
|
|
}
|
|
reuploadingId.value = assetId
|
|
try {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
await fetchData<{ url: string; sizeBytes: number }>(`/api/me/media/assets/${assetId}/reupload`, {
|
|
method: 'POST',
|
|
body: form,
|
|
notify: false,
|
|
})
|
|
previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => id !== assetId))
|
|
imgBust.value = { ...imgBust.value, [assetId]: Date.now() }
|
|
toast.add({ title: '已重新写入磁盘', color: 'success' })
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
reuploadingId.value = null
|
|
}
|
|
}
|
|
|
|
async function copyMarkdown(item: MediaAssetRow) {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
|
|
const md = ``
|
|
try {
|
|
await navigator.clipboard.writeText(md)
|
|
toast.add({ title: '已复制 Markdown', color: 'success' })
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6 w-full max-w-[min(100%,88rem)] px-0 sm:px-0">
|
|
<input
|
|
ref="reuploadInput"
|
|
type="file"
|
|
class="sr-only"
|
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
|
@change="onReuploadFile"
|
|
>
|
|
<div>
|
|
<h2 class="text-lg font-medium">
|
|
资源库
|
|
</h2>
|
|
<p class="text-sm text-muted mt-1">
|
|
浏览已上传图片、复制外链或 Markdown,用于文章与资料。
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-end gap-x-5 gap-y-3">
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="sr-only"
|
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
|
multiple
|
|
@change="onFileChange"
|
|
>
|
|
<UButton
|
|
color="primary"
|
|
:loading="uploading"
|
|
:disabled="uploading"
|
|
@click="openFilePicker"
|
|
>
|
|
选择图片上传
|
|
</UButton>
|
|
<UFormField label="搜索" class="min-w-[12rem] flex-1 max-w-xl">
|
|
<UInput
|
|
v-model="searchInput"
|
|
placeholder="文件名、引用(文章标题 / 用户名等)、文件描述"
|
|
icon="i-lucide-search"
|
|
class="w-full"
|
|
:disabled="uploading"
|
|
/>
|
|
</UFormField>
|
|
<UFormField label="每页" class="w-40 shrink-0">
|
|
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
|
|
</UFormField>
|
|
<UFormField label="视图密度" class="w-48 shrink-0">
|
|
<div class="flex w-full rounded-md border border-default p-1">
|
|
<UButton
|
|
size="xs"
|
|
class="flex-1 justify-center"
|
|
:variant="isCompact ? 'solid' : 'ghost'"
|
|
:color="isCompact ? 'primary' : 'neutral'"
|
|
@click="cardDensity = 'compact'"
|
|
>
|
|
简洁
|
|
</UButton>
|
|
<UButton
|
|
size="xs"
|
|
class="flex-1 justify-center"
|
|
:variant="!isCompact ? 'solid' : 'ghost'"
|
|
:color="!isCompact ? 'primary' : 'neutral'"
|
|
@click="cardDensity = 'detailed'"
|
|
>
|
|
复杂
|
|
</UButton>
|
|
</div>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="min-h-[22rem] sm:min-h-[26rem]">
|
|
<div
|
|
v-if="loading"
|
|
:class="
|
|
isCompact
|
|
? 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5'
|
|
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
|
|
"
|
|
aria-busy="true"
|
|
aria-label="加载中"
|
|
>
|
|
<div
|
|
v-for="n in skeletonPlaceholders"
|
|
:key="n"
|
|
class="rounded-lg border border-default bg-default p-3 space-y-2.5 shadow-sm"
|
|
>
|
|
<div class="relative w-full aspect-[4/3] rounded-md bg-elevated/80 animate-pulse" />
|
|
<div class="h-3 w-[85%] max-w-[12rem] rounded bg-elevated/80 animate-pulse" />
|
|
<div class="flex gap-2">
|
|
<div class="h-3 w-16 rounded bg-elevated/60 animate-pulse" />
|
|
<div class="h-3 w-24 rounded bg-elevated/60 animate-pulse" />
|
|
</div>
|
|
<div class="h-3 w-20 rounded bg-elevated/50 animate-pulse" />
|
|
<div class="h-8 w-full rounded bg-elevated/40 animate-pulse" />
|
|
</div>
|
|
</div>
|
|
<UEmpty
|
|
v-else-if="!items.length"
|
|
:title="appliedSearch ? '无匹配项' : '暂无图片'"
|
|
:description="
|
|
appliedSearch
|
|
? '没有匹配当前关键词的文件名、引用或描述,可更换关键词或清空搜索。'
|
|
: '上传 PNG / JPEG / WebP 后,将在此列出并可直接复制链接。'
|
|
"
|
|
:class="{ 'opacity-60 pointer-events-none': listBusy }"
|
|
/>
|
|
<div
|
|
v-else
|
|
:class="[
|
|
isCompact
|
|
? 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5'
|
|
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3',
|
|
{ 'opacity-60 pointer-events-none': listBusy },
|
|
]"
|
|
>
|
|
<UCard v-for="item in items" :key="item.id" class="p-0">
|
|
<div class="space-y-2.5 p-3">
|
|
<div class="relative w-full aspect-[4/3] rounded-md border border-default overflow-hidden bg-elevated/30">
|
|
<img
|
|
:src="imageSrc(item)"
|
|
:alt="item.storageKey"
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
@error="onImgError(item.id)"
|
|
/>
|
|
<div
|
|
v-if="previewFailedIds.has(item.id)"
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-default/80 p-2"
|
|
>
|
|
<p class="text-xs text-muted text-center">
|
|
文件缺失或无法加载
|
|
</p>
|
|
<UButton
|
|
size="xs"
|
|
color="primary"
|
|
:loading="reuploadingId === item.id"
|
|
:disabled="reuploadingId !== null && reuploadingId !== item.id"
|
|
@click="openReuploadPicker(item.id)"
|
|
>
|
|
重新上传
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="truncate font-mono text-xs text-default"
|
|
:title="item.storageKey"
|
|
>
|
|
{{ item.storageKey }}
|
|
</div>
|
|
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-[11px] text-muted tabular-nums">
|
|
<span>{{ formatBytes(item.sizeBytes) }}</span>
|
|
<span>{{ formatDt(item.createdAt) }}</span>
|
|
</div>
|
|
<p class="text-[11px] text-default">
|
|
引用数: {{ item.refCount }}
|
|
</p>
|
|
<div v-if="!isCompact && item.refContexts.length" class="text-xs space-y-1">
|
|
<UButton
|
|
size="xs"
|
|
variant="link"
|
|
color="neutral"
|
|
class="p-0 h-auto font-normal"
|
|
@click="toggleRefDetail(item.id)"
|
|
>
|
|
{{ refDetailOpen[item.id] ? '收起引用详情' : `查看引用详情(${item.refContexts.length})` }}
|
|
</UButton>
|
|
<div v-show="refDetailOpen[item.id]" class="text-muted space-y-1 border-l border-default pl-2 ml-0.5">
|
|
<div v-for="(c, idx) in item.refContexts" :key="idx">
|
|
<NuxtLink
|
|
v-if="c.href"
|
|
:to="c.href"
|
|
class="text-primary underline-offset-2 hover:underline"
|
|
>
|
|
{{ c.label }}
|
|
</NuxtLink>
|
|
<span v-else>{{ c.label }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="!isCompact" class="text-xs space-y-1">
|
|
<div class="text-muted">
|
|
文件描述
|
|
</div>
|
|
<template v-if="editingNoteId === item.id">
|
|
<UTextarea v-model="noteDraft[item.id]" :rows="2" class="text-xs w-full" />
|
|
<div class="flex flex-wrap gap-2">
|
|
<UButton
|
|
size="xs"
|
|
color="primary"
|
|
:loading="savingNoteId === item.id"
|
|
:disabled="loading || listBusy"
|
|
@click="saveMyNote(item.id)"
|
|
>
|
|
保存
|
|
</UButton>
|
|
<UButton
|
|
size="xs"
|
|
variant="ghost"
|
|
:disabled="savingNoteId === item.id"
|
|
@click="cancelEditNote(item)"
|
|
>
|
|
取消
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<p class="text-default whitespace-pre-wrap break-words min-h-[1.25rem]">
|
|
{{ (item.userNote ?? '').trim() ? item.userNote : '—' }}
|
|
</p>
|
|
<UButton size="xs" variant="soft" @click="startEditNote(item)">
|
|
修改描述
|
|
</UButton>
|
|
</template>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton size="xs" variant="soft" @click="copyUrl(item)">
|
|
复制 URL
|
|
</UButton>
|
|
<UButton size="xs" variant="soft" @click="copyMarkdown(item)">
|
|
复制 Markdown
|
|
</UButton>
|
|
<span class="inline-flex">
|
|
<UButton
|
|
size="xs"
|
|
color="error"
|
|
variant="soft"
|
|
:disabled="!item.canDelete || listBusy || loading || deletingId !== null"
|
|
@click="openDeleteConfirm(item)"
|
|
>
|
|
删除
|
|
</UButton>
|
|
</span>
|
|
</div>
|
|
<p
|
|
v-if="!isCompact && !item.canDelete && item.deleteBlockedReason"
|
|
class="text-[11px] text-muted leading-snug"
|
|
>
|
|
{{ deleteDisabledHint(item) }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="min-h-11 flex justify-end items-center">
|
|
<UPagination
|
|
v-if="!loading && !listBusy && total > pageSize"
|
|
v-model:page="page"
|
|
:total="total"
|
|
:items-per-page="pageSize"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
|
|
<UModal v-model:open="deleteConfirmOpen" title="确认删除图片">
|
|
<template #body>
|
|
<p class="text-sm text-default">
|
|
将永久删除文件并移除库记录,不可恢复。
|
|
</p>
|
|
<p
|
|
v-if="pendingDeleteAsset"
|
|
class="mt-2 font-mono text-xs text-muted break-all"
|
|
>
|
|
{{ pendingDeleteAsset.storageKey }}
|
|
</p>
|
|
</template>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<UButton
|
|
variant="outline"
|
|
color="neutral"
|
|
:disabled="deletingId !== null"
|
|
@click="closeDeleteModal"
|
|
>
|
|
取消
|
|
</UButton>
|
|
<UButton color="error" :loading="deletingId !== null" @click="executeDelete">
|
|
删除
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</template>
|
|
|