From 015e0dd2276699658c0e9852dac3873fd83865c7 Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 27 Aug 2026 06:19:17 +0800 Subject: [PATCH] feat(admin): add cloud type management --- API.md | 76 ++++++- src/stores/clouds.ts | 6 +- src/stores/encyclopedia.ts | 6 + src/types/api.ts | 4 + src/views/admin/AdminView.vue | 365 +++++++++++++++++++++++++++++++++- 5 files changed, 448 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 986c55f..e0d5dc8 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/src/stores/clouds.ts b/src/stores/clouds.ts index 04ef585..ac49833 100644 --- a/src/stores/clouds.ts +++ b/src/stores/clouds.ts @@ -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 } }) diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts index bff48dc..fd8bd1c 100644 --- a/src/stores/encyclopedia.ts +++ b/src/stores/encyclopedia.ts @@ -21,9 +21,15 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => { } } + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + cloudTypesLoaded.value = true + } + return { cloudTypes, loadingCloudTypes, fetchCloudTypes, + setCloudTypes, } }) diff --git a/src/types/api.ts b/src/types/api.ts index 9a96902..34478b0 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 { diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue index f17eafb..848b0e6 100644 --- a/src/views/admin/AdminView.vue +++ b/src/views/admin/AdminView.vue @@ -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({ }) const users = ref([]) const images = ref([]) +const cloudTypes = ref([]) const selectedReviewIds = ref>(new Set()) const activeReviewId = ref(null) const selectedImageIds = ref>(new Set()) @@ -89,6 +113,10 @@ const passwordTarget = ref(null) const newPassword = ref('') const confirmPassword = ref('') const passwordError = ref('') +const cloudTypeEditorOpen = ref(false) +const editingCloudType = ref(null) +const cloudTypeForm = ref(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('/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(`/admin/cloudtypes/${editingCloudType.value.id}`, { + method: 'PATCH', + body: payload, + }) + message.success('云类型已更新') + } else { + await apiRequest('/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(`/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) +
+
+
+ +

云类型管理

+

共 {{ cloudTypes.length }} 个类型,可维护名称、云属、稀有度和百科描述。

+
+ + + 添加云类型 + +
+ +
+
+
+
+

Type {{ cloudType.id }}

+

{{ cloudType.name }}

+

{{ cloudType.genus || '未设置云属' }}

+
+ + 稀有度 {{ cloudType.rarity }} + +
+ +
+

+ {{ cloudType.description || '暂无类型描述。' }} +

+
+
+
关联云图
+
{{ cloudTypeUsageCount(cloudType.id) }} 张
+
+
+
创建时间
+
{{ formatDateTime(cloudType.created_at) }}
+
+
+
+

图标 ID

+

{{ cloudType.icon_id }}

+
+
+ +
+ + + 编辑 + + + + 删除 + +
+
+
+ +
+ + + +
+
+
@@ -1274,6 +1540,95 @@ onMounted(loadAdminData)
+ +
+
+
+
+ +

+ {{ editingCloudType ? '编辑云类型' : '添加云类型' }} +

+

+ {{ editingCloudType ? `正在编辑类型 #${editingCloudType.id}` : '类型 ID 将由服务端自动生成。' }} +

+
+ +
+ +
+ + {{ cloudTypeFormError }} + + +
+ + +
+
+ + +

留空将保存为未设置。

+
+
+ + +

请输入整数,具体等级含义由后端数据约定。

+
+
+ + +

留空可清除已有图标关联。

+
+
+ + +
+
+ +
+ + 取消 + + + {{ editingCloudType ? '保存修改' : '添加类型' }} + +
+
+
+
+