feat(admin): add cloud type management
This commit is contained in:
1 parent
720ad29f06
commit
015e0dd227
5 files changed
+448
-9
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
cloudTypes,
|
||||
loadingCloudTypes,
|
||||
fetchCloudTypes,
|
||||
setCloudTypes,
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user