@@ -5,13 +5,16 @@ import {
Check ,
ChevronUp ,
ClipboardCheck ,
Cloud ,
Dashboard ,
Edit ,
Home ,
Key ,
LayoutSidebarLeftCollapse ,
LayoutSidebarLeftExpand ,
Logout ,
Photo ,
Plus ,
Refresh ,
Settings ,
Shield ,
@@ -23,14 +26,32 @@ import {
import { useRouter } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest , toApiCloud , toAuthUser } from '@/lib/api'
import { apiRequest , toApiCloud , toAuthUser , toCloudType } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ActionResponse , ApiCloud , AuthUser , BackendCloudInfo , BackendUserProfile } from '@/types/api '
import { useCloudsStore } from '@/stores/clouds '
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type {
ActionResponse ,
ApiCloud ,
AuthUser ,
BackendCloudInfo ,
BackendCloudType ,
BackendUserProfile ,
CloudTypeCreateResponse ,
} from '@/types/api'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
type AdminTab = 'dashboard' | 'review' | 'users' | 'cloud-types' | 'images'
type CloudStatus = 'pending' | 'approved' | 'rejected'
type ImageFilter = 'all' | CloudStatus
interface CloudTypeForm {
name : string
genus : string
iconId : string
rarity : string
description : string
}
interface AdminCloud {
id : string
user _id : string
@@ -60,6 +81,8 @@ interface DashboardStats {
}
const authStore = useAuthStore ( )
const cloudsStore = useCloudsStore ( )
const encyclopediaStore = useEncyclopediaStore ( )
const message = useMessage ( )
const router = useRouter ( )
@@ -80,6 +103,7 @@ const dashboardStats = ref<DashboardStats>({
} )
const users = ref < AuthUser [ ] > ( [ ] )
const images = ref < AdminCloud [ ] > ( [ ] )
const cloudTypes = ref < BackendCloudType [ ] > ( [ ] )
const selectedReviewIds = ref < Set < string > > ( new Set ( ) )
const activeReviewId = ref < string | null > ( null )
const selectedImageIds = ref < Set < string > > ( new Set ( ) )
@@ -89,6 +113,10 @@ const passwordTarget = ref<AuthUser | null>(null)
const newPassword = ref ( '' )
const confirmPassword = ref ( '' )
const passwordError = ref ( '' )
const cloudTypeEditorOpen = ref ( false )
const editingCloudType = ref < BackendCloudType | null > ( null )
const cloudTypeForm = ref < CloudTypeForm > ( emptyCloudTypeForm ( ) )
const cloudTypeFormError = ref ( '' )
const statusMeta = {
pending : { label : '待审核' , chip : 'border-amber-200 bg-amber-100 text-amber-700' } ,
@@ -100,6 +128,7 @@ const tabs = [
{ key : 'dashboard' , label : '数据看板' , description : '运行概览与内容分布' , icon : Dashboard } ,
{ key : 'review' , label : '内容审核' , description : '处理待审核上传' , icon : ClipboardCheck } ,
{ key : 'users' , label : '用户管理' , description : '角色与登录密码' , icon : Users } ,
{ key : 'cloud-types' , label : '云类型管理' , description : '分类资料与稀有度' , icon : Cloud } ,
{ key : 'images' , label : '图片管理' , description : '内容审核与删除' , icon : Photo } ,
] satisfies Array < { key : AdminTab ; label : string ; description : string ; icon : Component } >
@@ -191,7 +220,13 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
function formatDateTime ( iso : string | null ) {
if ( ! iso ) return '未知时间'
return new Date ( iso ) . toLocaleString ( 'zh-CN' , {
const compatibleIso = ( iso . includes ( 'T' ) ? iso : iso . replace ( ' ' , 'T' ) )
. replace ( /\.(\d{3})\d+/ , '.$1' )
. replace ( /([+-]\d{2})$/ , '$1:00' )
const date = new Date ( compatibleIso )
if ( Number . isNaN ( date . getTime ( ) ) ) return '未知时间'
return date . toLocaleString ( 'zh-CN' , {
year : 'numeric' ,
month : 'numeric' ,
day : 'numeric' ,
@@ -253,12 +288,21 @@ async function fetchImages() {
selectedImageIds . value = new Set ( [ ... selectedImageIds . value ] . filter ( id => images . value . some ( item => item . id === id ) ) )
}
async function fetchCloudTypes ( ) {
const rows = await apiRequest < BackendCloudType [ ] > ( '/info/cloudtype' , { auth : false } )
cloudTypes . value = rows
const normalized = rows . map ( toCloudType )
cloudsStore . setCloudTypes ( normalized )
encyclopediaStore . setCloudTypes ( normalized )
}
async function loadAdminData ( ) {
loading . value = true
loadError . value = ''
try {
await Promise . all ( [ fetchUsers ( ) , fetchImages ( ) ] )
await Promise . all ( [ fetchUsers ( ) , fetchImages ( ) , fetchCloudTypes ( ) ] )
syncDashboardStats ( )
} catch ( error ) {
loadError . value = error instanceof Error ? error . message : '管理后台加载失败'
@@ -468,6 +512,131 @@ async function resetUserPassword() {
}
}
function emptyCloudTypeForm ( ) : CloudTypeForm {
return {
name : '' ,
genus : '' ,
iconId : '' ,
rarity : '1' ,
description : '' ,
}
}
function openCloudTypeEditor ( cloudType ? : BackendCloudType ) {
editingCloudType . value = cloudType ? ? null
cloudTypeForm . value = cloudType
? {
name : cloudType . name ,
genus : cloudType . genus ? ? '' ,
iconId : cloudType . icon _id ? ? '' ,
rarity : String ( cloudType . rarity ) ,
description : cloudType . description ? ? '' ,
}
: emptyCloudTypeForm ( )
cloudTypeFormError . value = ''
cloudTypeEditorOpen . value = true
}
function closeCloudTypeEditor ( ) {
if ( actionLoading . value ) return
cloudTypeEditorOpen . value = false
editingCloudType . value = null
cloudTypeFormError . value = ''
}
function cloudTypePayload ( ) {
const name = cloudTypeForm . value . name . trim ( )
const genus = cloudTypeForm . value . genus . trim ( )
const iconId = cloudTypeForm . value . iconId . trim ( )
const description = cloudTypeForm . value . description
const rarity = Number ( cloudTypeForm . value . rarity )
if ( ! name ) {
cloudTypeFormError . value = '请输入云类型名称。'
return null
}
if ( ! Number . isInteger ( rarity ) ) {
cloudTypeFormError . value = '稀有度必须是整数。'
return null
}
if ( description . length > 128 ) {
cloudTypeFormError . value = '描述最多可填写 128 个字符。'
return null
}
if ( iconId && ! /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i . test ( iconId ) ) {
cloudTypeFormError . value = '图标 ID 必须是有效的 UUID。'
return null
}
return {
name ,
genus : genus || null ,
icon _id : iconId || null ,
rarity ,
description ,
}
}
async function saveCloudType ( ) {
cloudTypeFormError . value = ''
const payload = cloudTypePayload ( )
if ( ! payload ) return
actionLoading . value = true
loadError . value = ''
try {
if ( editingCloudType . value ) {
await apiRequest < ActionResponse > ( ` /admin/cloudtypes/ ${ editingCloudType . value . id } ` , {
method : 'PATCH' ,
body : payload ,
} )
message . success ( '云类型已更新' )
} else {
await apiRequest < CloudTypeCreateResponse > ( '/admin/cloudtypes' , {
method : 'POST' ,
body : payload ,
} )
message . success ( '云类型已添加' )
}
await fetchCloudTypes ( )
cloudTypeEditorOpen . value = false
editingCloudType . value = null
} catch ( error ) {
cloudTypeFormError . value = getErrorMessage ( error , editingCloudType . value ? '云类型更新失败' : '云类型添加失败' )
} finally {
actionLoading . value = false
}
}
function cloudTypeUsageCount ( cloudTypeId : number ) {
return images . value . filter ( image => image . cloud _type _id === cloudTypeId ) . length
}
async function deleteCloudType ( cloudType : BackendCloudType ) {
const usageCount = cloudTypeUsageCount ( cloudType . id )
if ( usageCount > 0 ) {
message . warning ( ` “ ${ cloudType . name } ”仍被 ${ usageCount } 张云图使用,无法删除 ` )
return
}
if ( ! window . confirm ( ` 确定删除云类型“ ${ cloudType . name } ”吗?删除后无法在页面中恢复。 ` ) ) return
actionLoading . value = true
loadError . value = ''
try {
await apiRequest < ActionResponse > ( ` /admin/cloudtypes/ ${ cloudType . id } ` , { method : 'DELETE' } )
await fetchCloudTypes ( )
message . success ( '云类型已删除' )
} catch ( error ) {
const text = getErrorMessage ( error , '云类型删除失败' )
loadError . value = text
message . error ( text )
} finally {
actionLoading . value = false
}
}
function toggleSidebar ( ) {
accountMenuOpen . value = false
sidebarCollapsed . value = ! sidebarCollapsed . value
@@ -1167,6 +1336,103 @@ onMounted(loadAdminData)
< / div >
< / section >
< section v-else-if = "activeTab === 'cloud-types'" >
< div class = "oc-panel-card mb-4 flex flex-col gap-4 border border-slate-200 bg-white p-4 sm:p-5 md:flex-row md:items-center md:justify-between" >
< div >
< p class = "oc-section-label" > Cloud Catalog < / p >
< h2 class = "mt-2 text-xl font-bold text-slate-950" > 云类型管理 < / h2 >
< p class = "oc-field-help" > 共 { { cloudTypes . length } } 个类型 , 可维护名称 、 云属 、 稀有度和百科描述 。 < / p >
< / div >
< NButton
type = "default"
secondary
strong
class = "oc-panel-button oc-panel-button--sky shrink-0"
@click ="openCloudTypeEditor()"
>
< template # icon > < NIcon > < Plus / > < / NIcon > < / template >
添加云类型
< / NButton >
< / div >
< div v-if = "cloudTypes.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" >
< article
v-for = "cloudType in cloudTypes"
:key = "cloudType.id"
class = "oc-panel-card flex min-w-0 flex-col border border-slate-200 bg-white"
>
< div class = "flex items-start justify-between gap-4 border-b border-slate-200 bg-sky-50/60 px-4 py-3" >
< div class = "min-w-0" >
< p class = "font-mono text-xs font-semibold uppercase tracking-[0.14em] text-sky-700" > Type { { cloudType . id } } < / p >
< h3 class = "mt-1 truncate text-lg font-bold text-slate-950" > { { cloudType . name } } < / h3 >
< p class = "mt-1 truncate text-sm text-slate-500" > { { cloudType . genus || '未设置云属' } } < / p >
< / div >
< span class = "shrink-0 border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-800" >
稀有度 { { cloudType . rarity } }
< / span >
< / div >
< div class = "flex flex-1 flex-col p-4" >
< p class = "min-h-12 text-sm leading-6 text-slate-700" >
{ { cloudType . description || '暂无类型描述。' } }
< / p >
< dl class = "mt-4 grid gap-3 border-t border-slate-100 pt-4 sm:grid-cols-2" >
< div >
< dt class = "text-xs font-medium text-slate-400" > 关联云图 < / dt >
< dd class = "mt-1 text-sm font-semibold text-slate-900" > { { cloudTypeUsageCount ( cloudType . id ) } } 张 < / dd >
< / div >
< div >
< dt class = "text-xs font-medium text-slate-400" > 创建时间 < / dt >
< dd class = "mt-1 text-sm text-slate-600" > { { formatDateTime ( cloudType . created _at ) } } < / dd >
< / div >
< / dl >
< div v-if = "cloudType.icon_id" class="mt-3 border border-slate-100 bg-slate-50 px-3 py-2" >
< p class = "text-xs font-medium text-slate-400" > 图标 ID < / p >
< p class = "mt-1 truncate font-mono text-xs text-slate-600" :title = "cloudType.icon_id" > { { cloudType . icon _id } } < / p >
< / div >
< / div >
< div class = "flex flex-wrap gap-2 border-t border-slate-200 bg-slate-50 px-4 py-3" >
< NButton
size = "small"
type = "default"
secondary
strong
class = "oc-panel-button oc-panel-button--neutral"
:disabled = "actionLoading"
@click ="openCloudTypeEditor(cloudType)"
>
< template # icon > < NIcon > < Edit / > < / NIcon > < / template >
编辑
< / NButton >
< NButton
size = "small"
type = "default"
secondary
strong
class = "oc-panel-button oc-panel-button--danger"
: disabled = "actionLoading || cloudTypeUsageCount(cloudType.id) > 0"
: title = "cloudTypeUsageCount(cloudType.id) > 0 ? '仍有云图使用该类型,无法删除' : '删除云类型'"
@click ="deleteCloudType(cloudType)"
>
< template # icon > < NIcon > < Trash / > < / NIcon > < / template >
删除
< / NButton >
< / div >
< / article >
< / div >
< div v-else class = "oc-empty-card border border-dashed border-slate-300 bg-white p-10" >
< NEmpty description = "还没有云类型" >
< template # extra >
< NButton type = "default" secondary strong class = "oc-panel-button oc-panel-button--sky" @click ="openCloudTypeEditor()" >
添加第一个云类型
< / NButton >
< / template >
< / NEmpty >
< / div >
< / section >
< section v-else >
< 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 >
@@ -1274,6 +1540,95 @@ onMounted(loadAdminData)
< / main >
< / div >
< Teleport to = "body" >
< div
v-if = "cloudTypeEditorOpen"
class = "fixed inset-0 z-[140] flex items-center justify-center overflow-y-auto bg-slate-950/60 px-4 py-6"
@click ="closeCloudTypeEditor"
>
< form class = "oc-modal-shell my-auto w-full max-w-2xl" @click.stop @submit.prevent ="saveCloudType" >
< 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 >
< p class = "oc-section-label" > Cloud Type Editor < / p >
< h3 class = "mt-2 text-xl font-semibold text-slate-900" >
{ { editingCloudType ? '编辑云类型' : '添加云类型' } }
< / h3 >
< p class = "oc-field-help" >
{ { editingCloudType ? ` 正在编辑类型 # ${ editingCloudType . id } ` : '类型 ID 将由服务端自动生成。' } }
< / p >
< / div >
< button
type = "button"
class = "oc-icon-button"
aria -label = " 关闭云类型编辑窗口 "
title = "关闭云类型编辑窗口"
@click ="closeCloudTypeEditor"
>
< NIcon size = "20" > < X / > < / NIcon >
< / button >
< / div >
< div class = "grid gap-4 px-5 py-5 sm:grid-cols-2 sm:px-6" >
< NAlert v-if = "cloudTypeFormError" class="sm:col-span-2" type="error" :show-icon="false" :bordered="false" >
{{ cloudTypeFormError }}
< / NAlert >
< div >
< label class = "oc-field-label" > 类型名称 < span class = "oc-field-required" > * < / span > < / label >
< NInput
v -model :value = "cloudTypeForm.name"
placeholder = "例如:积云"
: input -props = " { name : ' cloud -type -name ' , required : true } "
/ >
< / div >
< div >
< label class = "oc-field-label" > 云属 < / label >
< NInput v -model :value = "cloudTypeForm.genus" placeholder = "例如:Cumulus" / >
< p class = "oc-field-help" > 留空将保存为未设置 。 < / p >
< / div >
< div >
< label class = "oc-field-label" > 稀有度 < span class = "oc-field-required" > * < / span > < / label >
< input
v-model = "cloudTypeForm.rarity"
class = "oc-field-control"
type = "number"
step = "1"
inputmode = "numeric"
name = "cloud-type-rarity"
required
/ >
< p class = "oc-field-help" > 请输入整数 , 具体等级含义由后端数据约定 。 < / p >
< / div >
< div >
< label class = "oc-field-label" > 图标 ID < / label >
< NInput v -model :value = "cloudTypeForm.iconId" placeholder = "UUID,可留空" / >
< p class = "oc-field-help" > 留空可清除已有图标关联 。 < / p >
< / div >
< div class = "sm:col-span-2" >
< label class = "oc-field-label" > 描述 < span class = "oc-field-required" > * < / span > < / label >
< NInput
v -model :value = "cloudTypeForm.description"
type = "textarea"
:maxlength = "128"
show -count
: autosize = "{ minRows: 3, maxRows: 5 }"
placeholder = "填写辨识特征;允许留空"
/ >
< / div >
< / 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 ="closeCloudTypeEditor" >
取消
< / NButton >
< NButton attr -type = " submit " type = "default" secondary strong class = "oc-panel-button oc-panel-button--sky" :loading = "actionLoading" >
{ { editingCloudType ? '保存修改' : '添加类型' } }
< / NButton >
< / div >
< / form >
< / div >
< / Teleport >
< Teleport to = "body" >
< div
v-if = "passwordTarget"