feat(admin): add cloud type management

This commit is contained in:
2026-08-27 06:19:17 +08:00
parent 720ad29f06
commit 015e0dd227
5 changed files with 448 additions and 9 deletions
+73 -3
View File
@@ -1148,7 +1148,77 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。
### 8.6 `GET /admin/clouds` — 全状态云图列表
### 8.6 `POST /admin/cloudtypes` — 添加云类型
- **认证**:admin
**请求体**(JSON):
| 参数 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 |
| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 |
| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 |
| `rarity` | int | 是 | — | 稀有度 |
| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 |
```json
{
"name": "积云",
"genus": "Cumulus",
"icon_id": null,
"rarity": 1,
"description": "底部平坦、顶部蓬松的白色云块"
}
```
**成功响应**:`201`
```json
{
"status_code": 201,
"message": "云类型添加成功",
"cloud_type_id": 12
}
```
云类型 ID 由服务端生成;并发添加不会分配重复 ID。参数校验失败返回 400,数据库异常返回 500。
### 8.7 `PATCH /admin/cloudtypes/:cloud_type_id` — 修改云类型
- **认证**:admin
**路径参数**:`cloud_type_id`(int)。请求体字段与 8.6 相同,但均为可选;至少须提供一个字段。仅提交的字段会被修改,`genus` 和 `icon_id` 可显式传 `null` 清空。
**成功响应**:`200`
```json
{
"status_code": 200,
"message": "云类型修改成功"
}
```
**错误**:参数或空请求体无效返回 400;云类型不存在返回 404 `云类型不存在`;数据库异常返回 500。
### 8.8 `DELETE /admin/cloudtypes/:cloud_type_id` — 删除云类型
- **认证**:admin
**路径参数**:`cloud_type_id`(int)。
**成功响应**:`200`
```json
{
"status_code": 200,
"message": "云类型删除成功"
}
```
**错误**:参数无效返回 400;云类型不存在返回 404 `云类型不存在`;仍有云图使用该类型时返回 409 `云类型仍被云图使用,无法删除`;其他数据库异常返回 500。
### 8.9 `GET /admin/clouds` — 全状态云图列表
- **认证**:admin;可见任意状态与隐藏的云图
@@ -1167,7 +1237,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
### 8.7 `POST /admin/clouds/review` — 批量审核云图
### 8.10 `POST /admin/clouds/review` — 批量审核云图
- **认证**:admin
@@ -1205,7 +1275,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。
### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图
### 8.11 `DELETE /admin/clouds/:cloud_id` — 删除云图
- **认证**:admin
+5 -1
View File
@@ -19,5 +19,9 @@ export const useCloudsStore = defineStore('clouds', () => {
}
}
return { cloudTypes, loading, fetchCloudTypes }
function setCloudTypes(types: CloudType[]) {
cloudTypes.value = types
}
return { cloudTypes, loading, fetchCloudTypes, setCloudTypes }
})
+6
View File
@@ -21,9 +21,15 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
}
}
function setCloudTypes(types: CloudType[]) {
cloudTypes.value = types
cloudTypesLoaded.value = true
}
return {
cloudTypes,
loadingCloudTypes,
fetchCloudTypes,
setCloudTypes,
}
})
+4
View File
@@ -60,6 +60,10 @@ export interface CloudCreateResponse extends ActionResponse {
cloud_id: string
}
export interface CloudTypeCreateResponse extends ActionResponse {
cloud_type_id: number
}
// Internal view model. The backend profile is normalized here so existing
// layout/admin consumers do not need to understand the wire representation.
export interface AuthUser {
+360 -5
View File
@@ -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"