@@ -1,14 +1,13 @@
< script setup lang = "ts" >
import { computed , onMounted , ref , watch , type Component } from 'vue'
import { NAlert , NButton , NEmpty , NIcon , NPopover , NSkeleton , NTag , useMessage } from 'naive-ui'
import { NAlert , NButton , NEmpty , NIcon , NInput , NPopover , NSkeleton , NTag , useMessage } from 'naive-ui'
import {
Check ,
ChevronUp ,
ClipboardCheck ,
Dashboard ,
Eye ,
EyeOff ,
Home ,
Key ,
LayoutSidebarLeftCollapse ,
LayoutSidebarLeftExpand ,
Logout ,
@@ -24,10 +23,9 @@ import {
import { useRouter } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest } from '@/lib/api'
import { apiRequest , toApiCloud , toAuthUser } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminStats , AdminUserItem , ApiCloud , ApiPage } from '@/types/api'
import type { CloudType , Profile } from '@/types/database'
import type { ActionResponse , ApiCloud , AuthUser , BackendCloudInfo , BackendUserProfile } from '@/types/api'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
type CloudStatus = 'pending' | 'approved' | 'rejected'
@@ -37,19 +35,17 @@ interface AdminCloud {
id : string
user _id : string
cloud _type _id : number | null
custom _cloud _type : string | null
image _url : string
thumbnail _url : string | null
latitude : number | null
longitude : number | null
location _name : string | null
description : string | null
captured _at : string | null
created _at : string
status : CloudStatus
is _hidden : boolean
cloudTypeName : string
cloudTypeRarity : CloudType [ 'rarity' ]
cloudTypeRarity : number
username : string
}
@@ -82,19 +78,17 @@ const dashboardStats = ref<DashboardStats>({
rejected : 0 ,
hidden : 0 ,
} )
const users = ref < Profile [ ] > ( [ ] )
const users = ref < AuthUser [ ] > ( [ ] )
const images = ref < AdminCloud [ ] > ( [ ] )
const selectedReviewIds = ref < Set < string > > ( new Set ( ) )
const activeReviewId = ref < string | null > ( null )
const selectedImageIds = ref < Set < string > > ( new Set ( ) )
const imageFilter = ref < ImageFilter > ( 'all' )
const selectedImage = ref < AdminCloud | null > ( null )
const rarityMeta = {
common : { label : '常见' , chip : 'border-sky-200 bg-sky-100 text-sky-700' } ,
uncommon : { label : '少见' , chip : 'border-amber-200 bg-amber-100 text-amber-700' } ,
rare : { label : '罕见' , chip : 'border-rose-200 bg-rose-100 text-rose-700' } ,
} satisfies Record < CloudType [ ' rarity ' ] , { label : string ; chip : string } >
const passwordTarget = ref < AuthUser | null > ( null )
const newPassword = ref ( '' )
const confirmPassword = ref ( '' )
const passwordError = ref ( '' )
const statusMeta = {
pending : { label : '待审核' , chip : 'border-amber-200 bg-amber-100 text-amber-700' } ,
@@ -105,8 +99,8 @@ const statusMeta = {
const tabs = [
{ key : 'dashboard' , label : '数据看板' , description : '运行概览与内容分布' , icon : Dashboard } ,
{ key : 'review' , label : '内容审核' , description : '处理待审核上传' , icon : ClipboardCheck } ,
{ key : 'users' , label : '用户管理' , description : '角色与账号状态 ' , icon : Users } ,
{ key : 'images' , label : '图片管理' , description : '内容状态与可见性 ' , icon : Photo } ,
{ key : 'users' , label : '用户管理' , description : '角色与登录密码 ' , icon : Users } ,
{ key : 'images' , label : '图片管理' , description : '内容审核与删除 ' , icon : Photo } ,
] satisfies Array < { key : AdminTab ; label : string ; description : string ; icon : Component } >
const pendingImages = computed ( ( ) => images . value . filter ( item => item . status === 'pending' ) )
@@ -144,7 +138,9 @@ const cloudTypeStats = computed(() => {
const reviewedCount = computed ( ( ) => dashboardStats . value . approved + dashboardStats . value . rejected )
const approvalRate = computed ( ( ) => getPercentage ( dashboardStats . value . approved , reviewedCount . value ) )
const publicImageCount = computed ( ( ) => Math . max ( 0 , dashboardStats . value . images - dashboardStats . value . hidden ) )
const publicImageCount = computed ( ( ) =>
images . value . filter ( item => item . status === 'approved' && ! item . is _hidden ) . length ,
)
const recentImages = computed ( ( ) => images . value . slice ( 0 , 5 ) )
const cloudTypeMaxCount = computed ( ( ) => Math . max ( 1 , ... cloudTypeStats . value . map ( item => item . count ) ) )
const reviewStatusStats = computed ( ( ) => [
@@ -178,19 +174,17 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
id : row . id ,
user _id : row . user _id ,
cloud _type _id : row . cloud _type _id ,
custom _cloud _type : null ,
image _url : row . image _url ,
thumbnail _url : row . thumbnail _url ,
latitude : row . latitude ,
longitude : row . longitude ,
location _name : null ,
description : row . description ,
captured _at : row . captured _at ,
created _at : row . created _at ,
status : row . status ,
is _hidden : row . is _hidden ,
cloudTypeName : row . cloud _type _name || '未知云型' ,
cloudTypeRarity : row . cloud _type _rarity || 'common' ,
cloudTypeRarity : row . cloud _type _rarity _value ? ? 0 ,
username : row . username || '匿名用户' ,
}
}
@@ -219,27 +213,42 @@ function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error . message : fallback
}
async function fetchStats ( ) {
const stats = await apiRequest < AdminStats > ( '/admin/stats' )
function syncDashboardStats ( ) {
const now = new Date ( )
const isToday = ( value : string ) => {
const date = new Date ( value )
return date . getFullYear ( ) === now . getFullYear ( )
&& date . getMonth ( ) === now . getMonth ( )
&& date . getDate ( ) === now . getDate ( )
}
dashboardStats . value = {
users : stats . users ,
images : stats . images ,
todayUploads : stats . today _uploads ,
pending : stats . pending ,
approved : stats . approved ,
rejected : stats . rejected ,
hidden : stats . hidden ,
users : users . value . length ,
images : images . value . length ,
todayUploads : images . value . filter ( item => isToday ( item . created _at ) ) . length ,
pending : images . value . filter ( item => item . status === 'pending' ) . length ,
approved : images . value . filter ( item => item . status === 'approved' ) . length ,
rejected : images . value . filter ( item => item . status === 'rejected' ) . length ,
hidden : images . value . filter ( item => item . is _hidden ) . length ,
}
}
async function fetchUsers ( ) {
const result = await apiRequest < ApiPage < AdminUserItem > > ( '/admin/users?page=1&page_size=100 ' )
users . value = result . items . map ( item => item . user )
const rows = await apiRequest < BackendUserProfile [ ] > ( '/admin/users' )
users . value = rows . map ( toAuthUser )
}
async function fetchImages ( ) {
const result = await apiRequest < ApiPage < ApiCloud > > ( '/admin/clouds?page=1&page_size=120' )
images . value = result . items . map ( toAdminCloud )
const rows : BackendCloudInfo [ ] = [ ]
const pageSize = 100
for ( let page = 1 ; ; page ++ ) {
const current = await apiRequest < BackendCloudInfo [ ] > ( ` /admin/clouds?page= ${ page } &page_size= ${ pageSize } ` )
rows . push ( ... current )
if ( current . length < pageSize ) break
}
images . value = rows . map ( toApiCloud ) . map ( toAdminCloud )
selectedReviewIds . value = new Set ( [ ... selectedReviewIds . value ] . filter ( id => images . value . some ( item => item . id === id ) ) )
selectedImageIds . value = new Set ( [ ... selectedImageIds . value ] . filter ( id => images . value . some ( item => item . id === id ) ) )
}
@@ -249,11 +258,8 @@ async function loadAdminData() {
loadError . value = ''
try {
await Promise . all ( [
fetchStats ( ) ,
fetchUsers ( ) ,
fetchImages ( ) ,
] )
await Promise . all ( [ fetchUsers ( ) , fetchImages ( ) ] )
syncDashboardStats ( )
} catch ( error ) {
loadError . value = error instanceof Error ? error . message : '管理后台加载失败'
} finally {
@@ -305,21 +311,13 @@ function toggleAllFilteredImageSelection() {
setSelectedImageIds ( filteredImages . value . map ( item => item . id ) )
}
function patchImages ( ids : string [ ] , patch : Partial < AdminCloud > ) {
const idSet = new Set ( ids )
images . value = images . value . map ( item => ( idSet . has ( item . id ) ? { ... item , ... patch } : item ) )
if ( selectedImage . value && idSet . has ( selectedImage . value . id ) ) {
selectedImage . value = { ... selectedImage . value , ... patch }
}
}
function chunkIds ( ids : string [ ] ) {
return Array . from ( { length : Math . ceil ( ids . length / 100 ) } , ( _ , index ) =>
ids . slice ( index * 100 , ( index + 1 ) * 100 ) ,
)
}
async function updateCloudStatus ( ids : string [ ] , status : CloudStatus ) {
async function updateCloudStatus ( ids : string [ ] , status : Exclude < CloudStatus , ' pending ' > ) {
if ( ! ids . length ) return
actionLoading . value = true
@@ -327,17 +325,20 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
try {
for ( const chunk of chunkIds ( ids ) ) {
await apiRequest ( '/admin/clouds/status ' , {
method : 'PATCH ' ,
body : { ids : chunk , status } ,
await apiRequest < ActionResponse > ( '/admin/clouds/review ' , {
method : 'POST ' ,
body : {
cloud _ids : chunk . map ( cloudId => ( { cloud _id : cloudId } ) ) ,
status ,
} ,
} )
}
patchImages ( ids , { status } )
await fetchImages ( )
syncDashboardStats ( )
setSelectedReviewIds ( [ ... selectedReviewIds . value ] . filter ( id => ! ids . includes ( id ) ) )
setSelectedImageIds ( [ ... selectedImageIds . value ] . filter ( id => ! ids . includes ( id ) ) )
await fetchStats ( )
message . success ( ` 已 ${ status === 'approved' ? '通过' : '拒绝' } ${ ids . length } 张图片 ` )
message . success ( '审核操作已完成,列表已刷新' )
} catch ( error ) {
const text = getErrorMessage ( error , '审核操作失败' )
loadError . value = text
@@ -347,37 +348,6 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
}
}
async function updateImageVisibility ( ids : string [ ] , isHidden : boolean ) {
if ( ! ids . length ) return
actionLoading . value = true
loadError . value = ''
try {
for ( const chunk of chunkIds ( ids ) ) {
await apiRequest ( '/admin/clouds/visibility' , {
method : 'PATCH' ,
body : { ids : chunk , is _hidden : isHidden } ,
} )
}
patchImages ( ids , { is _hidden : isHidden } )
setSelectedImageIds ( [ ... selectedImageIds . value ] . filter ( id => ! ids . includes ( id ) ) )
await fetchStats ( )
message . success ( isHidden ? ` 已隐藏 ${ ids . length } 张图片 ` : ` 已公开 ${ ids . length } 张图片 ` )
} catch ( error ) {
const text = getErrorMessage ( error , '可见性更新失败' )
loadError . value = text
message . error ( text )
} finally {
actionLoading . value = false
}
}
async function toggleImageVisibility ( cloud : AdminCloud ) {
await updateImageVisibility ( [ cloud . id ] , ! cloud . is _hidden )
}
async function deleteImages ( clouds : AdminCloud [ ] ) {
if ( ! clouds . length ) return
@@ -392,22 +362,33 @@ async function deleteImages(clouds: AdminCloud[]) {
loadError . value = ''
try {
const idsToDelete = clouds . map ( cloud => cloud . id )
for ( const chunk of chunkIds ( idsToDelete ) ) {
await apiRequest ( '/admin/clouds/batch-delete' , {
method : 'POST' ,
body : { ids : chunk } ,
} )
const deletedIds : string [ ] = [ ]
let firstError = ''
for ( const cloud of clouds ) {
try {
await apiRequest < ActionResponse > ( ` /admin/clouds/ ${ cloud . id } ` , { method : 'DELETE' } )
deletedIds . push ( cloud . id )
} catch ( error ) {
firstError || = getErrorMessage ( error , '图片删除失败' )
}
}
images . value = images . value . filter ( item => ! idsToDelete . includes ( item . id ) )
if ( selectedImage . value && idsToDelete . includes ( selectedImage . value . id ) ) {
await fetchImages ( )
syncDashboardStats ( )
if ( selectedImage . value && deletedIds . includes ( selectedImage . value . id ) ) {
selectedImage . value = null
}
setSelectedReviewIds ( [ ... selectedReviewIds . value ] . filter ( id => ! idsToDelete . includes ( id ) ) )
setSelectedImageIds ( [ ... selectedImageIds . value ] . filter ( id => ! idsToDelete . includes ( id ) ) )
await fetchStats ( )
message . success ( clouds . length === 1 ? '图片已删除' : ` 已删除 ${ clouds . length } 张图片 ` )
setSelectedReviewIds ( [ ... selectedReviewIds . value ] . filter ( id => ! deletedIds . includes ( id ) ) )
setSelectedImageIds ( [ ... selectedImageIds . value ] . filter ( id => ! deletedIds . includes ( id ) ) )
if ( deletedIds . length === clouds . length ) {
message . success ( clouds . length === 1 ? '图片已删除' : ` 已删除 ${ clouds . length } 张图片 ` )
} else {
const text = ` 已删除 ${ deletedIds . length } 张, ${ clouds . length - deletedIds . length } 张失败 ${ firstError ? ` : ${ firstError } ` : '' } `
loadError . value = text
message . error ( text )
}
} catch ( error ) {
const text = getErrorMessage ( error , '图片删除失败' )
loadError . value = text
@@ -421,7 +402,7 @@ async function deleteImage(cloud: AdminCloud) {
await deleteImages ( [ cloud ] )
}
async function updateUserRole ( user : Profile , role : Profile [ 'role' ] ) {
async function updateUserRole ( user : AuthUser , role : AuthUser [ 'role' ] ) {
if ( user . id === authStore . user ? . id && role !== 'admin' ) {
message . warning ( '不能移除自己的管理员权限' )
return
@@ -431,12 +412,12 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
loadError . value = ''
try {
const result = await apiRequest < AdminUserItem > ( ` /admin/users/ ${ user . id } ` , {
await apiRequest < ActionResponse > ( ` /admin/users/ ${ user . id } ` , {
method : 'PATCH' ,
body : { role } ,
body : { user _role : role } ,
} )
users . value = users . value . map ( item => item . id === user . id ? result . user : item )
message . success ( '用户角色已更新' )
users . value = users . value . map ( item => item . id === user . id ? { ... item , role } : item )
message . success ( '用户角色已更新;该用户需要重新登录 ' )
} catch ( error ) {
const text = getErrorMessage ( error , '用户角色更新失败' )
loadError . value = text
@@ -446,27 +427,42 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
}
}
async function toggleUserDisabled ( user : Profile ) {
if ( user . id === authStore . user ? . id ) {
message . warning ( '不能禁用当前登录的管理员账号' )
function openPasswordReset ( user : AuthUser ) {
passwordTarget . value = user
newPassword . value = ''
confirmPassword . value = ''
passwordError . value = ''
}
function closePasswordReset ( ) {
if ( actionLoading . value ) return
passwordTarget . value = null
}
async function resetUserPassword ( ) {
const user = passwordTarget . value
if ( ! user ) return
passwordError . value = ''
if ( newPassword . value . length < 8 ) {
passwordError . value = '新密码至少需要 8 位。'
return
}
if ( newPassword . value !== confirmPassword . value ) {
passwordError . value = '两次输入的密码不一致。'
return
}
actionLoading . value = true
loadError . value = ''
try {
const nextDisabled = ! user . is _disabled
const result = await apiRequest < AdminUserItem > ( ` /admin/users/ ${ user . id } ` , {
await apiRequest < ActionResponse > ( ` /admin/users/ ${ user . id } /password ` , {
method : 'PATCH' ,
body : { is _disabled : nextDisabled } ,
body : { new _password : newPassword . value } ,
} )
users . value = users . value . map ( item => item . id === user . id ? result . user : item )
message . success ( nextDisabled ? '用户已禁用' : '用户已恢复 ')
passwordTarget . value = null
message . success ( '密码已重置;该用户需要重新登录 ')
} catch ( error ) {
const text = getErrorMessage ( error , '用户状态更新 失败' )
loadError . value = text
message . error ( text )
passwordError . value = getErrorMessage ( error , '密码重置 失败' )
} finally {
actionLoading . value = false
}
@@ -877,7 +873,9 @@ onMounted(loadAdminData)
< img : src = "item.thumbnail_url || item.image_url" :alt = "item.cloudTypeName" class = "h-11 w-13 object-cover" / >
< span class = "min-w-0" >
< span class = "block truncate text-sm font-semibold text-slate-900" > { { item . cloudTypeName } } < / span >
< span class = "mt-0.5 block truncate text-xs text-slate-500" > { { item . username } } · { { item . location _name || '未填写位置' } } < / span >
< span class = "mt-0.5 block truncate text-xs text-slate-500" >
{ { item . username } } · { { formatCoordinate ( item . latitude ) } } , { { formatCoordinate ( item . longitude ) } }
< / span >
< / span >
< span class = "hidden truncate text-xs text-slate-500 sm:block" > { { formatDateTime ( item . created _at ) } } < / span >
< span class = "border px-2 py-1 text-xs font-medium" :class = "statusMeta[item.status].chip" >
@@ -985,8 +983,8 @@ onMounted(loadAdminData)
< 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 >
< / div >
< span class = "shrink-0 whitespace-nowrap border px-2 py-1 text-xs font-medium" :class = "rarityMeta[activeReviewImage.cloudTypeRarity].chip ">
{ { rarityMeta [ activeReviewImage . cloudTypeRarity ] . label } }
< span class = "shrink-0 whitespace-nowrap border border-sky-200 bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700 " >
稀有度 { { activeReviewImage . cloudTypeRarity } }
< / span >
< / div >
< button
@@ -1048,10 +1046,6 @@ onMounted(loadAdminData)
< 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" >
@@ -1109,26 +1103,39 @@ onMounted(loadAdminData)
< section v-else-if = "activeTab === 'users'" >
< div class = "oc-panel-card overflow-hidden border border-slate-200 bg-white" >
< div class = "grid grid-cols-[1.1fr_ 0.8 fr_0.7fr_0.9fr_ 1.2 fr] border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500" >
< div class = "hidden grid-cols-[minmax(0,1.1fr)_minmax(0, 0.7 fr)_minmax(0,0.7fr)_minmax(0,0.9fr)_minmax(0, 1.5 fr)] gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 lg:grid " >
< span > 用户 < / span >
< span > 角色 < / span >
< span > 状态 < / span >
< span > 注册时间 < / span >
< span > 操作 < / span >
< / div >
< div v-for = "user in users" :key="user.id" class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] items-center gap-3 border-b border-slate-100 px-4 py-4 last:border-b-0" >
< div class = "min-w-0" >
< div
v - for = "user in users"
: key = "user.id"
class = "grid gap-4 border-b border-slate-100 px-4 py-4 last:border-b-0 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,0.9fr)_minmax(0,1.5fr)] lg:items-center lg:gap-3"
>
< div class = "min-w-0 sm:col-span-2 lg:col-span-1" >
< p class = "truncate font-medium text-slate-950" > { { user . username } } < / p >
< p class = "mt-1 truncate text-xs text-slate-400" > { { user . id } } < / p >
< p class = "mt-1 truncate text-xs text-slate-400" > { { user . email } } < / p >
< / div >
< NTag size = "small" :bordered = "false" : type = "user.role === 'admin' ? 'success' : 'default' ">
{ { user . role === 'admin' ? '管理员' : '用户' } }
< / NTag >
< NTag size = "small" :bordered = "false" : type = "user.is_disabled ? 'error' : 'success'" >
{ { user . is _disabled ? '已禁用' : '正常' } }
< / NTag >
< span class = "text-sm text-slate-500" > { { formatDateTime ( user . created _at ) } } < / span >
< div class = "flex flex-wrap gap-2" >
< div class = "flex items-center justify-between gap-3 lg:block " >
< span class = "text-xs font-medium text-slate-400 lg:hidden" > 角色 < / span >
< NTag size = "small" :bordered = "false" : type = "user.role === 'admin' ? 'success' : 'default'" >
{ { user . role === 'admin' ? '管理员' : '用户' } }
< / NTag >
< / div >
< div class = "flex items-center justify-between gap-3 lg:block" >
< span class = "text-xs font-medium text-slate-400 lg:hidden" > 状态 < / span >
< NTag size = "small" :bordered = "false" : type = "user.is_disabled ? 'error' : 'success'" >
{ { user . is _disabled ? '已禁用' : '正常' } }
< / NTag >
< / div >
< div class = "flex items-center justify-between gap-3 text-sm text-slate-500 lg:block" >
< span class = "text-xs font-medium text-slate-400 lg:hidden" > 注册时间 < / span >
< span > { { formatDateTime ( user . created _at ) } } < / span >
< / div >
< div class = "flex flex-wrap gap-2 sm:col-span-2 lg:col-span-1" >
< NButton
size = "small"
secondary
@@ -1146,12 +1153,14 @@ onMounted(loadAdminData)
type = "default"
secondary
strong
: class = "user.is_disabled ? ' oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--danger' "
class = "oc-panel-button oc-panel-button--neutral "
: disabled = "user.id === authStore.user?.id"
: loading = "actionLoading"
@ click = "toggleUserDisabled(user) "
: title = "user.id === authStore.user?.id ? '请在账号设置中修改自己的密码' : '设置新密码' "
@ click = "openPasswordReset(user)"
>
{ { user . is _disabled ? '恢复' : '禁用' } }
< template # icon > < NIcon > < Key / > < / NIcon > < / template >
重置密码
< / NButton >
< / div >
< / div >
@@ -1162,7 +1171,7 @@ onMounted(loadAdminData)
< div class = "oc-panel-card mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between" >
< div >
< h2 class = "text-xl font-bold text-slate-950" > 图片管理 < / h2 >
< p class = "oc-field-help" > 最近 { { images . length } } 张图片 , 可调整审核状态 、 可见性 或删除。 < / p >
< p class = "oc-field-help" > 共 { { images . length } } 张图片 , 可调整审核状态或删除 。 < / p >
< / div >
< div class = "flex flex-wrap gap-2" >
< select
@@ -1205,26 +1214,6 @@ onMounted(loadAdminData)
>
批量拒绝 { { selectedManagedCount } }
< / NButton >
< NButton
secondary
strong
type = "default"
class = "oc-panel-button oc-panel-button--amber"
: loading = "actionLoading"
@ click = "updateImageVisibility(Array.from(selectedImageIds), true)"
>
批量隐藏 { { selectedManagedCount } }
< / NButton >
< NButton
secondary
strong
type = "default"
class = "oc-panel-button oc-panel-button--teal"
: loading = "actionLoading"
@ click = "updateImageVisibility(Array.from(selectedImageIds), false)"
>
批量公开 { { selectedManagedCount } }
< / NButton >
< NButton
secondary
strong
@@ -1248,7 +1237,9 @@ onMounted(loadAdminData)
< div class = "flex items-start justify-between gap-3" >
< div class = "min-w-0" >
< p class = "truncate font-semibold text-slate-950" > { { item . cloudTypeName } } < / p >
< p class = "mt-1 truncate text-sm text-slate-500" > { { item . username } } · { { item . location _name || '未填写位置' } } < / p >
< p class = "mt-1 truncate text-sm text-slate-500" >
{ { item . username } } · { { formatCoordinate ( item . latitude ) } } , { { formatCoordinate ( item . longitude ) } }
< / p >
< / div >
< div class = "flex items-start gap-2" >
< NTag size = "small" :bordered = "false" :class = "statusMeta[item.status].chip" >
@@ -1266,15 +1257,6 @@ onMounted(loadAdminData)
< NButton size = "small" type = "default" secondary strong class = "oc-panel-button oc-panel-button--neutral" @click ="selectedImage = item" > 查看 < / NButton >
< NButton size = "small" type = "default" secondary strong class = "oc-panel-button oc-panel-button--teal" : disabled = "item.status === 'approved'" :loading = "actionLoading" @click ="updateCloudStatus([item.id], 'approved')" > 通过 < / NButton >
< NButton size = "small" type = "default" secondary strong class = "oc-panel-button oc-panel-button--danger" : disabled = "item.status === 'rejected'" :loading = "actionLoading" @click ="updateCloudStatus([item.id], 'rejected')" > 拒绝 < / NButton >
< NButton size = "small" type = "default" secondary strong class = "oc-panel-button oc-panel-button--amber" :loading = "actionLoading" @click ="toggleImageVisibility(item)" >
< template # icon >
< NIcon >
< Eye v-if = "item.is_hidden" / >
< EyeOff v-else / >
< / NIcon >
< / template >
{ { item . is _hidden ? '公开' : '隐藏' } }
< / NButton >
< NButton size = "small" type = "default" secondary strong class = "oc-panel-button oc-panel-button--danger" :loading = "actionLoading" @click ="deleteImage(item)" >
< template # icon > < NIcon > < Trash / > < / NIcon > < / template >
删除
@@ -1292,6 +1274,71 @@ onMounted(loadAdminData)
< / main >
< / div >
< Teleport to = "body" >
< div
v - if = "passwordTarget"
class = "fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/60 px-4 py-6"
@ click = "closePasswordReset"
>
< form class = "oc-modal-shell w-full max-w-lg" @click.stop @submit.prevent ="resetUserPassword" >
< div class = "flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4 sm:px-6 sm:py-5" >
< div class = "min-w-0" >
< p class = "oc-section-label" > Admin Credential < / p >
< h3 class = "mt-2 text-xl font-semibold text-slate-900" > 重置用户密码 < / h3 >
< p class = "oc-field-help truncate" > @ { { passwordTarget . username } } · { { passwordTarget . email } } < / p >
< / div >
< button
type = "button"
class = "oc-icon-button"
aria - label = "关闭密码重置窗口"
title = "关闭密码重置窗口"
@ click = "closePasswordReset"
>
< NIcon size = "20" > < X / > < / NIcon >
< / button >
< / div >
< div class = "grid gap-4 px-5 py-5 sm:px-6" >
< NAlert v-if = "passwordError" type="error" :show-icon="false" :bordered="false" >
{ { passwordError } }
< / NAlert >
< div >
< label class = "oc-field-label" > 新密码 < span class = "oc-field-required" > * < / span > < / label >
< NInput
v - model : value = "newPassword"
type = "password"
show - password - on = "click"
placeholder = "至少 8 位"
: input - props = "{ autocomplete: 'new-password', name: 'admin-new-password', required: true }"
/ >
< / div >
< div >
< label class = "oc-field-label" > 确认新密码 < span class = "oc-field-required" > * < / span > < / label >
< NInput
v - model : value = "confirmPassword"
type = "password"
show - password - on = "click"
placeholder = "再次输入新密码"
: input - props = "{ autocomplete: 'new-password', name: 'admin-confirm-new-password', required: true }"
/ >
< / div >
< p class = "border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs leading-5 text-amber-800" >
保存后 , 该用户所有设备上的现有登录状态都会立即失效 。
< / p >
< / div >
< div class = "flex items-center justify-end gap-3 border-t border-slate-200 px-5 py-4 sm:px-6" >
< NButton type = "default" secondary strong class = "oc-panel-button oc-panel-button--neutral" :disabled = "actionLoading" @click ="closePasswordReset" >
取消
< / NButton >
< NButton attr -type = " submit " type = "default" secondary strong class = "oc-panel-button oc-panel-button--amber" :loading = "actionLoading" >
确认重置
< / NButton >
< / div >
< / form >
< / div >
< / Teleport >
< ImageDetailModal
v - if = "selectedImage"
: open = "!!selectedImage"
@@ -1300,8 +1347,8 @@ onMounted(loadAdminData)
: image - alt = "selectedImage.cloudTypeName"
: title = "selectedImage.cloudTypeName"
: subtitle = "`上传者:${selectedImage.username}`"
: badge - label = "rarityMeta[ selectedImage.cloudTypeRarity].label "
: badge - class = "rarityMeta[selectedImage.cloudTypeRarity].chip "
: badge - label = "`稀有度 ${ selectedImage.cloudTypeRarity}` "
badge - class = "border-sky-200 bg-sky-100 text-sky-700 "
@ close = "selectedImage = null"
>
< div class = "grid gap-4 sm:grid-cols-2" >
@@ -1336,7 +1383,6 @@ onMounted(loadAdminData)
< MiniLocationMap
: latitude = "selectedImage.latitude"
: longitude = "selectedImage.longitude"
: location - name = "selectedImage.location_name"
/ >
< / ImageDetailModal >
< / div >