feat(admin): add focused image review workspace
This commit is contained in:
+170
-26
@@ -62,6 +62,7 @@ const dashboardStats = ref<DashboardStats>({
|
|||||||
const users = ref<Profile[]>([])
|
const users = ref<Profile[]>([])
|
||||||
const images = ref<AdminCloud[]>([])
|
const images = ref<AdminCloud[]>([])
|
||||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||||
|
const activeReviewId = ref<string | null>(null)
|
||||||
const selectedImageIds = ref<Set<string>>(new Set())
|
const selectedImageIds = ref<Set<string>>(new Set())
|
||||||
const imageFilter = ref<ImageFilter>('all')
|
const imageFilter = ref<ImageFilter>('all')
|
||||||
const selectedImage = ref<AdminCloud | null>(null)
|
const selectedImage = ref<AdminCloud | null>(null)
|
||||||
@@ -86,6 +87,13 @@ const tabs = [
|
|||||||
] satisfies Array<{ key: AdminTab; label: string; code: string; description: string }>
|
] satisfies Array<{ key: AdminTab; label: string; code: string; description: string }>
|
||||||
|
|
||||||
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
|
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
|
||||||
|
const activeReviewImage = computed(() =>
|
||||||
|
pendingImages.value.find(item => item.id === activeReviewId.value) ?? pendingImages.value[0] ?? null,
|
||||||
|
)
|
||||||
|
const activeReviewPosition = computed(() => {
|
||||||
|
if (!activeReviewImage.value) return 0
|
||||||
|
return pendingImages.value.findIndex(item => item.id === activeReviewImage.value?.id) + 1
|
||||||
|
})
|
||||||
const activeTabMeta = computed(() => tabs.find(tab => tab.key === activeTab.value) ?? tabs[0])
|
const activeTabMeta = computed(() => tabs.find(tab => tab.key === activeTab.value) ?? tabs[0])
|
||||||
const filteredImages = computed(() => {
|
const filteredImages = computed(() => {
|
||||||
if (imageFilter.value === 'all') return images.value
|
if (imageFilter.value === 'all') return images.value
|
||||||
@@ -173,7 +181,7 @@ function formatDateTime(iso: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
function formatCoordinate(value: number | null) {
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
return value === null ? '未记录' : value.toFixed(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPercentage(value: number, total: number) {
|
function getPercentage(value: number, total: number) {
|
||||||
@@ -736,39 +744,175 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pendingImages.length" class="grid gap-4 lg:grid-cols-2">
|
<div v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
<article v-for="item in pendingImages" :key="item.id" class="grid gap-4 border border-slate-200 bg-white p-4 md:grid-cols-[180px_minmax(0,1fr)]">
|
<aside class="min-w-0 border border-slate-200 bg-white xl:sticky xl:top-20 xl:max-h-[calc(100vh-6rem)]">
|
||||||
<button type="button" class="overflow-hidden bg-slate-100" @click="selectedImage = item">
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-44 w-full object-cover transition-transform hover:scale-105" />
|
<div>
|
||||||
</button>
|
<p class="text-sm font-semibold text-slate-900">审核队列</p>
|
||||||
<div class="min-w-0">
|
<p class="mt-0.5 text-xs text-slate-500">{{ activeReviewPosition }} / {{ pendingImages.length }}</p>
|
||||||
<div class="flex items-start justify-between gap-3">
|
</div>
|
||||||
<div>
|
<span class="border border-amber-200 bg-amber-100 px-2 py-1 text-xs font-semibold text-amber-800">
|
||||||
<p class="text-lg font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
{{ pendingImages.length }}
|
||||||
<p class="oc-field-help">上传者:{{ item.username }}</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2 overflow-x-auto p-2 xl:max-h-[calc(100vh-11rem)] xl:block xl:space-y-2 xl:overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in pendingImages"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex min-w-[210px] items-center gap-2 border p-2 transition-colors xl:min-w-0"
|
||||||
|
:class="activeReviewImage.id === item.id
|
||||||
|
? 'border-teal-300 bg-teal-50'
|
||||||
|
: 'border-transparent bg-white hover:border-slate-200 hover:bg-slate-50'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600"
|
||||||
|
:aria-pressed="activeReviewImage.id === item.id"
|
||||||
|
@click="activeReviewId = item.id"
|
||||||
|
>
|
||||||
|
<span class="relative shrink-0">
|
||||||
|
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-12 w-14 object-cover" />
|
||||||
|
<span class="absolute left-0 top-0 bg-slate-950/80 px-1 font-mono text-[10px] text-white">{{ index + 1 }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-sm font-semibold text-slate-900">{{ item.cloudTypeName }}</span>
|
||||||
|
<span class="mt-1 block truncate text-xs text-slate-500">{{ item.username }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="mt-1 h-5 w-5"
|
class="h-5 w-5 shrink-0"
|
||||||
|
:aria-label="`选择 ${item.cloudTypeName}`"
|
||||||
:checked="selectedReviewIds.has(item.id)"
|
:checked="selectedReviewIds.has(item.id)"
|
||||||
@change="toggleReviewSelection(item.id)"
|
@change="toggleReviewSelection(item.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm text-slate-600">{{ item.location_name || '未填写位置' }} · {{ formatDateTime(item.created_at) }}</p>
|
</div>
|
||||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">{{ item.description || '没有图片说明。' }}</p>
|
</aside>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">
|
<div class="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
<template #icon><NIcon><Check /></NIcon></template>
|
<div class="min-w-0 space-y-4">
|
||||||
通过
|
<div class="border border-slate-200 bg-white">
|
||||||
</NButton>
|
<div class="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3">
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">
|
<div class="min-w-0">
|
||||||
<template #icon><NIcon><X /></NIcon></template>
|
<p class="truncate text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</p>
|
||||||
拒绝
|
<p class="mt-0.5 truncate text-xs text-slate-500">内容编号 {{ activeReviewImage.id }}</p>
|
||||||
</NButton>
|
</div>
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看大图</NButton>
|
<span class="border px-2 py-1 text-xs font-medium" :class="rarityMeta[activeReviewImage.cloudTypeRarity].chip">
|
||||||
|
{{ rarityMeta[activeReviewImage.cloudTypeRarity].label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex min-h-[360px] w-full items-center justify-center overflow-hidden bg-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-500 sm:min-h-[480px]"
|
||||||
|
aria-label="打开图片大图"
|
||||||
|
@click="selectedImage = activeReviewImage"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="activeReviewImage.image_url"
|
||||||
|
:alt="activeReviewImage.cloudTypeName"
|
||||||
|
class="max-h-[620px] w-full object-contain transition-transform duration-300 group-hover:scale-[1.01]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center justify-between gap-4 border-t border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<p class="text-xs text-slate-500">点击图片可查看原图与地图</p>
|
||||||
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = activeReviewImage">
|
||||||
|
查看完整详情
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-slate-200 bg-white p-5">
|
||||||
|
<p class="oc-section-label">Uploader Notes</p>
|
||||||
|
<h3 class="mt-2 text-lg font-bold text-slate-950">上传说明</h3>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ activeReviewImage.description || '上传者没有填写图片说明。' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
<aside class="self-start border border-slate-200 bg-white lg:sticky lg:top-20">
|
||||||
|
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#fffbeb_0%,#fff_100%)] p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Review Decision</p>
|
||||||
|
<h3 class="mt-2 text-xl font-bold text-slate-950">审核资料</h3>
|
||||||
|
<p class="mt-2 text-xs leading-5 text-slate-500">核对图片内容、云型标注、时间与位置后作出决定。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="divide-y divide-slate-100 px-5">
|
||||||
|
<div class="py-3">
|
||||||
|
<dt class="text-xs font-medium text-slate-400">上传者</dt>
|
||||||
|
<dd class="mt-1 text-sm font-semibold text-slate-900">@{{ activeReviewImage.username }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 py-3">
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-slate-400">云型标注</dt>
|
||||||
|
<dd class="mt-1 text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-slate-400">可见性</dt>
|
||||||
|
<dd class="mt-1 text-sm font-semibold text-slate-900">{{ activeReviewImage.is_hidden ? '隐藏' : '公开' }}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<dt class="text-xs font-medium text-slate-400">拍摄时间</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.captured_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<dt class="text-xs font-medium text-slate-400">上传时间</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.created_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<dt class="text-xs font-medium text-slate-400">位置名称</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-slate-800">{{ activeReviewImage.location_name || '未填写位置名称' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<dt class="text-xs font-medium text-slate-400">模糊化坐标</dt>
|
||||||
|
<dd class="mt-1 font-mono text-xs leading-5 text-slate-700">
|
||||||
|
LAT {{ formatCoordinate(activeReviewImage.latitude) }}<br />
|
||||||
|
LNG {{ formatCoordinate(activeReviewImage.longitude) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="space-y-2 border-t border-slate-200 bg-slate-50 p-4">
|
||||||
|
<NButton
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateCloudStatus([activeReviewImage.id], 'approved')"
|
||||||
|
>
|
||||||
|
<template #icon><NIcon><Check /></NIcon></template>
|
||||||
|
通过并处理下一张
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--danger"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateCloudStatus([activeReviewImage.id], 'rejected')"
|
||||||
|
>
|
||||||
|
<template #icon><NIcon><X /></NIcon></template>
|
||||||
|
拒绝并处理下一张
|
||||||
|
</NButton>
|
||||||
|
<label class="mt-3 flex cursor-pointer items-center gap-3 border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5"
|
||||||
|
:checked="selectedReviewIds.has(activeReviewImage.id)"
|
||||||
|
@change="toggleReviewSelection(activeReviewImage.id)"
|
||||||
|
/>
|
||||||
|
加入批量选择
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
||||||
|
|||||||
Reference in New Issue
Block a user