feat(admin): add cloud type management
This commit is contained in:
@@ -1148,7 +1148,77 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
|||||||
|
|
||||||
> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。
|
> ⚠️ **假端点**(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;可见任意状态与隐藏的云图
|
- **认证**:admin;可见任意状态与隐藏的云图
|
||||||
|
|
||||||
@@ -1167,7 +1237,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
|||||||
| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 |
|
| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 |
|
||||||
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
||||||
|
|
||||||
### 8.7 `POST /admin/clouds/review` — 批量审核云图
|
### 8.10 `POST /admin/clouds/review` — 批量审核云图
|
||||||
|
|
||||||
- **认证**:admin
|
- **认证**:admin
|
||||||
|
|
||||||
@@ -1205,7 +1275,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
|||||||
|
|
||||||
**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。
|
**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。
|
||||||
|
|
||||||
### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图
|
### 8.11 `DELETE /admin/clouds/:cloud_id` — 删除云图
|
||||||
|
|
||||||
- **认证**:admin
|
- **认证**:admin
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,9 +21,15 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCloudTypes(types: CloudType[]) {
|
||||||
|
cloudTypes.value = types
|
||||||
|
cloudTypesLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cloudTypes,
|
cloudTypes,
|
||||||
loadingCloudTypes,
|
loadingCloudTypes,
|
||||||
fetchCloudTypes,
|
fetchCloudTypes,
|
||||||
|
setCloudTypes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export interface CloudCreateResponse extends ActionResponse {
|
|||||||
cloud_id: string
|
cloud_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CloudTypeCreateResponse extends ActionResponse {
|
||||||
|
cloud_type_id: number
|
||||||
|
}
|
||||||
|
|
||||||
// Internal view model. The backend profile is normalized here so existing
|
// Internal view model. The backend profile is normalized here so existing
|
||||||
// layout/admin consumers do not need to understand the wire representation.
|
// layout/admin consumers do not need to understand the wire representation.
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Cloud,
|
||||||
Dashboard,
|
Dashboard,
|
||||||
|
Edit,
|
||||||
Home,
|
Home,
|
||||||
Key,
|
Key,
|
||||||
LayoutSidebarLeftCollapse,
|
LayoutSidebarLeftCollapse,
|
||||||
LayoutSidebarLeftExpand,
|
LayoutSidebarLeftExpand,
|
||||||
Logout,
|
Logout,
|
||||||
Photo,
|
Photo,
|
||||||
|
Plus,
|
||||||
Refresh,
|
Refresh,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -23,14 +26,32 @@ import {
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.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 { 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 CloudStatus = 'pending' | 'approved' | 'rejected'
|
||||||
type ImageFilter = 'all' | CloudStatus
|
type ImageFilter = 'all' | CloudStatus
|
||||||
|
|
||||||
|
interface CloudTypeForm {
|
||||||
|
name: string
|
||||||
|
genus: string
|
||||||
|
iconId: string
|
||||||
|
rarity: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
interface AdminCloud {
|
interface AdminCloud {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
@@ -60,6 +81,8 @@ interface DashboardStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -80,6 +103,7 @@ const dashboardStats = ref<DashboardStats>({
|
|||||||
})
|
})
|
||||||
const users = ref<AuthUser[]>([])
|
const users = ref<AuthUser[]>([])
|
||||||
const images = ref<AdminCloud[]>([])
|
const images = ref<AdminCloud[]>([])
|
||||||
|
const cloudTypes = ref<BackendCloudType[]>([])
|
||||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||||
const activeReviewId = ref<string | null>(null)
|
const activeReviewId = ref<string | null>(null)
|
||||||
const selectedImageIds = ref<Set<string>>(new Set())
|
const selectedImageIds = ref<Set<string>>(new Set())
|
||||||
@@ -89,6 +113,10 @@ const passwordTarget = ref<AuthUser | null>(null)
|
|||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const passwordError = ref('')
|
const passwordError = ref('')
|
||||||
|
const cloudTypeEditorOpen = ref(false)
|
||||||
|
const editingCloudType = ref<BackendCloudType | null>(null)
|
||||||
|
const cloudTypeForm = ref<CloudTypeForm>(emptyCloudTypeForm())
|
||||||
|
const cloudTypeFormError = ref('')
|
||||||
|
|
||||||
const statusMeta = {
|
const statusMeta = {
|
||||||
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
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: 'dashboard', label: '数据看板', description: '运行概览与内容分布', icon: Dashboard },
|
||||||
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
|
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
|
||||||
{ key: 'users', label: '用户管理', description: '角色与登录密码', icon: Users },
|
{ key: 'users', label: '用户管理', description: '角色与登录密码', icon: Users },
|
||||||
|
{ key: 'cloud-types', label: '云类型管理', description: '分类资料与稀有度', icon: Cloud },
|
||||||
{ key: 'images', label: '图片管理', description: '内容审核与删除', icon: Photo },
|
{ key: 'images', label: '图片管理', description: '内容审核与删除', icon: Photo },
|
||||||
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
|
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
|
||||||
|
|
||||||
@@ -191,7 +220,13 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
|
|||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
function formatDateTime(iso: string | null) {
|
||||||
if (!iso) return '未知时间'
|
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',
|
year: 'numeric',
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
day: '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)))
|
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() {
|
async function loadAdminData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([fetchUsers(), fetchImages()])
|
await Promise.all([fetchUsers(), fetchImages(), fetchCloudTypes()])
|
||||||
syncDashboardStats()
|
syncDashboardStats()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
|
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() {
|
function toggleSidebar() {
|
||||||
accountMenuOpen.value = false
|
accountMenuOpen.value = false
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
@@ -1167,6 +1336,103 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<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 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>
|
<div>
|
||||||
@@ -1274,6 +1540,95 @@ onMounted(loadAdminData)
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="passwordTarget"
|
v-if="passwordTarget"
|
||||||
|
|||||||
Reference in New Issue
Block a user