feat(admin): align frontend with updated API contracts
This commit is contained in:
@@ -217,7 +217,7 @@
|
||||
- 续签不适用的路径:`POST /auth/logout`、`PATCH /auth/password`
|
||||
- `/image` 请求只验证身份,不触发静默续签,避免一个页面的并发图片请求重复写 Cookie
|
||||
- 认证中间件每次请求都会重新检查用户仍已确认邮箱且未被管理员禁用;任一条件不满足时,已有 JWT 也会被拒绝
|
||||
- **立即失效**的四种情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、通过 `/auth/reset-password` 重置密码、用户被禁用。前三者会使该用户**所有已签发的 token 全部作废**(包括其他设备上的);密码修改与重置成功时还会写入过期 Cookie
|
||||
- **立即失效**的情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、通过 `/auth/reset-password` 重置密码、用户被禁用,以及管理员修改了该用户的角色或密码(`PATCH /admin/users/:user_id` 见 8.3、`PATCH /admin/users/:user_id/password` 见 8.4)。除「用户被禁用」由中间件每次请求重新校验外,其余情况都会递增 `tokenVersion`,使该用户**所有已签发的 token 全部作废**(包括其他设备上的);用户自己改密与密码重置成功时还会写入过期 Cookie
|
||||
|
||||
### 3.5 认证相关错误消息对照
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
- 新用户的 `is_disabled` 为 `false`,`email_verified_at` 为空;两个状态相互独立。注册后必须先确认邮箱,再调用 `/auth/login`
|
||||
- 注册响应**不返回 token、不写 Cookie**
|
||||
- 确认邮件由 `Opencloud <opencloud@catpl.top>` 发出,同时包含 `https://cloud.catpl.top/verify-email?token=...` 链接和原始 token 备用文本。数据库仅保存使用 `JWT_SECRET` 生成的 HMAC 摘要
|
||||
- ⚠️ 并发注册(同一邮箱/用户名几乎同时提交)命中数据库唯一约束时,冲突消息文案与上方表格不同(邮箱冲突会返回 `该邮箱已被注册;若尚未验证,请使用重发验证流程`)。按本文档免责声明,前端仍只依据状态码 `409` 处理
|
||||
|
||||
### 4.2 `POST /auth/resend-confirmation` — 重发确认邮件
|
||||
|
||||
@@ -1053,7 +1054,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
||||
|
||||
### 8.1 `GET /admin/stats` — 统计数据
|
||||
|
||||
> ⚠️ **未实现**(占位端点)。当前任何请求返回纯文本 `404 Not Found`。请勿接入。
|
||||
> ⚠️ **未实现**(占位端点,无 handler)。admin 请求返回纯文本 `404 Not Found`;未登录/非 admin 仍按本章规则先返回 401/403。请勿接入。
|
||||
|
||||
### 8.2 `GET /admin/users` — 用户列表
|
||||
|
||||
@@ -1069,15 +1070,85 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
||||
|
||||
**注意事项**:**无分页参数**,一次返回全部用户。用户量大时注意性能。
|
||||
|
||||
### 8.3 `PATCH /admin/users` — 更新用户
|
||||
### 8.3 `PATCH /admin/users/:user_id` — 修改用户角色
|
||||
|
||||
> ⚠️ **未实现**(占位端点)。当前任何请求返回纯文本 `404 Not Found`。请勿接入。
|
||||
- **认证**:admin
|
||||
|
||||
### 8.4 `POST /admin/user` — 创建用户
|
||||
**路径参数**:`user_id`(uuid)。
|
||||
|
||||
**请求体**(JSON):
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `user_role` | enum | 是 | `user` / `admin` | 目标角色 |
|
||||
|
||||
```json
|
||||
{
|
||||
"user_role": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "角色修改成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误**:
|
||||
|
||||
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|
||||
|---|---|---|---|
|
||||
| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 |
|
||||
| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 |
|
||||
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
||||
|
||||
**注意事项**:修改角色会同时递增 `tokenVersion`,**该用户所有已签发 token 立即失效,必须重新登录**(见 3.4)。前端改完角色后可提示目标用户重新登录。
|
||||
|
||||
### 8.4 `PATCH /admin/users/:user_id/password` — 设置用户密码
|
||||
|
||||
- **认证**:admin
|
||||
|
||||
**路径参数**:`user_id`(uuid)。
|
||||
|
||||
**请求体**(JSON):
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `new_password` | string | 是 | 无强度校验 | 新密码;管理员直接覆盖,无需旧密码 |
|
||||
|
||||
```json
|
||||
{
|
||||
"new_password": "new-s3cret-password"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "密码修改成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误**:
|
||||
|
||||
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|
||||
|---|---|---|---|
|
||||
| 400 | 参数校验失败(`user_id` 非 uuid) | zod 形状(2.1) | 检查参数 |
|
||||
| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 |
|
||||
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
||||
|
||||
**注意事项**:成功后同样递增 `tokenVersion`,该用户所有已签发 token 立即失效,必须重新登录(见 3.4)。
|
||||
|
||||
### 8.5 `POST /admin/user` — 创建用户
|
||||
|
||||
> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。
|
||||
|
||||
### 8.5 `GET /admin/clouds` — 全状态云图列表
|
||||
### 8.6 `GET /admin/clouds` — 全状态云图列表
|
||||
|
||||
- **认证**:admin;可见任意状态与隐藏的云图
|
||||
|
||||
@@ -1096,7 +1167,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
||||
| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 |
|
||||
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
||||
|
||||
### 8.6 `POST /admin/clouds/review` — 批量审核云图
|
||||
### 8.7 `POST /admin/clouds/review` — 批量审核云图
|
||||
|
||||
- **认证**:admin
|
||||
|
||||
@@ -1134,6 +1205,31 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|
||||
|
||||
**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。
|
||||
|
||||
### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图
|
||||
|
||||
- **认证**:admin
|
||||
|
||||
**路径参数**:`cloud_id`(uuid)。
|
||||
|
||||
**成功响应**:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"message": "删除成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误**:
|
||||
|
||||
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|
||||
|---|---|---|---|
|
||||
| 400 | `cloud_id` 非 uuid | zod 形状(2.1) | 检查链接 |
|
||||
| 404 | 云图不存在 | `图片不存在` | 提示云图不存在 |
|
||||
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
|
||||
|
||||
**注意事项**:管理员删除不受审核状态或隐藏状态限制。删除会级联删除该云图的点赞记录,并尽力清理 MinIO 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。
|
||||
|
||||
---
|
||||
|
||||
## 9. 端点 · 系统
|
||||
|
||||
@@ -74,14 +74,6 @@ export interface AuthUser {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiPage<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export interface ApiCloudTypeSummary {
|
||||
id: number
|
||||
name: string
|
||||
@@ -104,17 +96,3 @@ export interface ApiCloud extends Cloud {
|
||||
username: string | null
|
||||
received_like_count: number
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users: number
|
||||
images: number
|
||||
today_uploads: number
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
hidden: number
|
||||
}
|
||||
|
||||
export interface AdminUserItem {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
+211
-165
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, type Component } from 'vue'
|
||||
import { NAlert, NButton, NEmpty, NIcon, NPopover, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import { NAlert, NButton, NEmpty, NIcon, NInput, NPopover, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import {
|
||||
Check,
|
||||
ChevronUp,
|
||||
ClipboardCheck,
|
||||
Dashboard,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Home,
|
||||
Key,
|
||||
LayoutSidebarLeftCollapse,
|
||||
LayoutSidebarLeftExpand,
|
||||
Logout,
|
||||
@@ -24,10 +23,9 @@ import {
|
||||
import { useRouter } from 'vue-router'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { apiRequest, toApiCloud, toAuthUser } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
import type { ActionResponse, ApiCloud, AuthUser, BackendCloudInfo, BackendUserProfile } from '@/types/api'
|
||||
|
||||
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
|
||||
type CloudStatus = 'pending' | 'approved' | 'rejected'
|
||||
@@ -37,19 +35,17 @@ interface AdminCloud {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
status: CloudStatus
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
cloudTypeRarity: number
|
||||
username: string
|
||||
}
|
||||
|
||||
@@ -82,19 +78,17 @@ const dashboardStats = ref<DashboardStats>({
|
||||
rejected: 0,
|
||||
hidden: 0,
|
||||
})
|
||||
const users = ref<Profile[]>([])
|
||||
const users = ref<AuthUser[]>([])
|
||||
const images = ref<AdminCloud[]>([])
|
||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||
const activeReviewId = ref<string | null>(null)
|
||||
const selectedImageIds = ref<Set<string>>(new Set())
|
||||
const imageFilter = ref<ImageFilter>('all')
|
||||
const selectedImage = ref<AdminCloud | null>(null)
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'border-sky-200 bg-sky-100 text-sky-700' },
|
||||
uncommon: { label: '少见', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
||||
rare: { label: '罕见', chip: 'border-rose-200 bg-rose-100 text-rose-700' },
|
||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
||||
const passwordTarget = ref<AuthUser | null>(null)
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const passwordError = ref('')
|
||||
|
||||
const statusMeta = {
|
||||
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
||||
@@ -105,8 +99,8 @@ const statusMeta = {
|
||||
const tabs = [
|
||||
{ key: 'dashboard', label: '数据看板', description: '运行概览与内容分布', icon: Dashboard },
|
||||
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
|
||||
{ key: 'users', label: '用户管理', description: '角色与账号状态', icon: Users },
|
||||
{ key: 'images', label: '图片管理', description: '内容状态与可见性', icon: Photo },
|
||||
{ key: 'users', label: '用户管理', description: '角色与登录密码', icon: Users },
|
||||
{ key: 'images', label: '图片管理', description: '内容审核与删除', icon: Photo },
|
||||
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
|
||||
|
||||
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
|
||||
@@ -144,7 +138,9 @@ const cloudTypeStats = computed(() => {
|
||||
|
||||
const reviewedCount = computed(() => dashboardStats.value.approved + dashboardStats.value.rejected)
|
||||
const approvalRate = computed(() => getPercentage(dashboardStats.value.approved, reviewedCount.value))
|
||||
const publicImageCount = computed(() => Math.max(0, dashboardStats.value.images - dashboardStats.value.hidden))
|
||||
const publicImageCount = computed(() =>
|
||||
images.value.filter(item => item.status === 'approved' && !item.is_hidden).length,
|
||||
)
|
||||
const recentImages = computed(() => images.value.slice(0, 5))
|
||||
const cloudTypeMaxCount = computed(() => Math.max(1, ...cloudTypeStats.value.map(item => item.count)))
|
||||
const reviewStatusStats = computed(() => [
|
||||
@@ -178,19 +174,17 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
cloud_type_id: row.cloud_type_id,
|
||||
custom_cloud_type: null,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
location_name: null,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || '未知云型',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
username: row.username || '匿名用户',
|
||||
}
|
||||
}
|
||||
@@ -219,27 +213,42 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
const stats = await apiRequest<AdminStats>('/admin/stats')
|
||||
function syncDashboardStats() {
|
||||
const now = new Date()
|
||||
const isToday = (value: string) => {
|
||||
const date = new Date(value)
|
||||
return date.getFullYear() === now.getFullYear()
|
||||
&& date.getMonth() === now.getMonth()
|
||||
&& date.getDate() === now.getDate()
|
||||
}
|
||||
|
||||
dashboardStats.value = {
|
||||
users: stats.users,
|
||||
images: stats.images,
|
||||
todayUploads: stats.today_uploads,
|
||||
pending: stats.pending,
|
||||
approved: stats.approved,
|
||||
rejected: stats.rejected,
|
||||
hidden: stats.hidden,
|
||||
users: users.value.length,
|
||||
images: images.value.length,
|
||||
todayUploads: images.value.filter(item => isToday(item.created_at)).length,
|
||||
pending: images.value.filter(item => item.status === 'pending').length,
|
||||
approved: images.value.filter(item => item.status === 'approved').length,
|
||||
rejected: images.value.filter(item => item.status === 'rejected').length,
|
||||
hidden: images.value.filter(item => item.is_hidden).length,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
|
||||
users.value = result.items.map(item => item.user)
|
||||
const rows = await apiRequest<BackendUserProfile[]>('/admin/users')
|
||||
users.value = rows.map(toAuthUser)
|
||||
}
|
||||
|
||||
async function fetchImages() {
|
||||
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
|
||||
images.value = result.items.map(toAdminCloud)
|
||||
const rows: BackendCloudInfo[] = []
|
||||
const pageSize = 100
|
||||
|
||||
for (let page = 1; ; page++) {
|
||||
const current = await apiRequest<BackendCloudInfo[]>(`/admin/clouds?page=${page}&page_size=${pageSize}`)
|
||||
rows.push(...current)
|
||||
if (current.length < pageSize) break
|
||||
}
|
||||
|
||||
images.value = rows.map(toApiCloud).map(toAdminCloud)
|
||||
selectedReviewIds.value = new Set([...selectedReviewIds.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)))
|
||||
}
|
||||
@@ -249,11 +258,8 @@ async function loadAdminData() {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStats(),
|
||||
fetchUsers(),
|
||||
fetchImages(),
|
||||
])
|
||||
await Promise.all([fetchUsers(), fetchImages()])
|
||||
syncDashboardStats()
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
|
||||
} finally {
|
||||
@@ -305,21 +311,13 @@ function toggleAllFilteredImageSelection() {
|
||||
setSelectedImageIds(filteredImages.value.map(item => item.id))
|
||||
}
|
||||
|
||||
function patchImages(ids: string[], patch: Partial<AdminCloud>) {
|
||||
const idSet = new Set(ids)
|
||||
images.value = images.value.map(item => (idSet.has(item.id) ? { ...item, ...patch } : item))
|
||||
if (selectedImage.value && idSet.has(selectedImage.value.id)) {
|
||||
selectedImage.value = { ...selectedImage.value, ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function chunkIds(ids: string[]) {
|
||||
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
|
||||
ids.slice(index * 100, (index + 1) * 100),
|
||||
)
|
||||
}
|
||||
|
||||
async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||
async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pending'>) {
|
||||
if (!ids.length) return
|
||||
|
||||
actionLoading.value = true
|
||||
@@ -327,17 +325,20 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||
|
||||
try {
|
||||
for (const chunk of chunkIds(ids)) {
|
||||
await apiRequest('/admin/clouds/status', {
|
||||
method: 'PATCH',
|
||||
body: { ids: chunk, status },
|
||||
await apiRequest<ActionResponse>('/admin/clouds/review', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cloud_ids: chunk.map(cloudId => ({ cloud_id: cloudId })),
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
patchImages(ids, { status })
|
||||
await fetchImages()
|
||||
syncDashboardStats()
|
||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
||||
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||
await fetchStats()
|
||||
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
||||
message.success('审核操作已完成,列表已刷新')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '审核操作失败')
|
||||
loadError.value = text
|
||||
@@ -347,37 +348,6 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateImageVisibility(ids: string[], isHidden: boolean) {
|
||||
if (!ids.length) return
|
||||
|
||||
actionLoading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
for (const chunk of chunkIds(ids)) {
|
||||
await apiRequest('/admin/clouds/visibility', {
|
||||
method: 'PATCH',
|
||||
body: { ids: chunk, is_hidden: isHidden },
|
||||
})
|
||||
}
|
||||
|
||||
patchImages(ids, { is_hidden: isHidden })
|
||||
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||
await fetchStats()
|
||||
message.success(isHidden ? `已隐藏 ${ids.length} 张图片` : `已公开 ${ids.length} 张图片`)
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '可见性更新失败')
|
||||
loadError.value = text
|
||||
message.error(text)
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleImageVisibility(cloud: AdminCloud) {
|
||||
await updateImageVisibility([cloud.id], !cloud.is_hidden)
|
||||
}
|
||||
|
||||
async function deleteImages(clouds: AdminCloud[]) {
|
||||
if (!clouds.length) return
|
||||
|
||||
@@ -392,22 +362,33 @@ async function deleteImages(clouds: AdminCloud[]) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const idsToDelete = clouds.map(cloud => cloud.id)
|
||||
for (const chunk of chunkIds(idsToDelete)) {
|
||||
await apiRequest('/admin/clouds/batch-delete', {
|
||||
method: 'POST',
|
||||
body: { ids: chunk },
|
||||
})
|
||||
const deletedIds: string[] = []
|
||||
let firstError = ''
|
||||
|
||||
for (const cloud of clouds) {
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/admin/clouds/${cloud.id}`, { method: 'DELETE' })
|
||||
deletedIds.push(cloud.id)
|
||||
} catch (error) {
|
||||
firstError ||= getErrorMessage(error, '图片删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
|
||||
if (selectedImage.value && idsToDelete.includes(selectedImage.value.id)) {
|
||||
await fetchImages()
|
||||
syncDashboardStats()
|
||||
if (selectedImage.value && deletedIds.includes(selectedImage.value.id)) {
|
||||
selectedImage.value = null
|
||||
}
|
||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !idsToDelete.includes(id)))
|
||||
setSelectedImageIds([...selectedImageIds.value].filter(id => !idsToDelete.includes(id)))
|
||||
await fetchStats()
|
||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !deletedIds.includes(id)))
|
||||
setSelectedImageIds([...selectedImageIds.value].filter(id => !deletedIds.includes(id)))
|
||||
|
||||
if (deletedIds.length === clouds.length) {
|
||||
message.success(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
|
||||
} else {
|
||||
const text = `已删除 ${deletedIds.length} 张,${clouds.length - deletedIds.length} 张失败${firstError ? `:${firstError}` : ''}`
|
||||
loadError.value = text
|
||||
message.error(text)
|
||||
}
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '图片删除失败')
|
||||
loadError.value = text
|
||||
@@ -421,7 +402,7 @@ async function deleteImage(cloud: AdminCloud) {
|
||||
await deleteImages([cloud])
|
||||
}
|
||||
|
||||
async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||
async function updateUserRole(user: AuthUser, role: AuthUser['role']) {
|
||||
if (user.id === authStore.user?.id && role !== 'admin') {
|
||||
message.warning('不能移除自己的管理员权限')
|
||||
return
|
||||
@@ -431,12 +412,12 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||||
await apiRequest<ActionResponse>(`/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
body: { user_role: role },
|
||||
})
|
||||
users.value = users.value.map(item => item.id === user.id ? result.user : item)
|
||||
message.success('用户角色已更新')
|
||||
users.value = users.value.map(item => item.id === user.id ? { ...item, role } : item)
|
||||
message.success('用户角色已更新;该用户需要重新登录')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户角色更新失败')
|
||||
loadError.value = text
|
||||
@@ -446,27 +427,42 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserDisabled(user: Profile) {
|
||||
if (user.id === authStore.user?.id) {
|
||||
message.warning('不能禁用当前登录的管理员账号')
|
||||
function openPasswordReset(user: AuthUser) {
|
||||
passwordTarget.value = user
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
passwordError.value = ''
|
||||
}
|
||||
|
||||
function closePasswordReset() {
|
||||
if (actionLoading.value) return
|
||||
passwordTarget.value = null
|
||||
}
|
||||
|
||||
async function resetUserPassword() {
|
||||
const user = passwordTarget.value
|
||||
if (!user) return
|
||||
|
||||
passwordError.value = ''
|
||||
if (newPassword.value.length < 8) {
|
||||
passwordError.value = '新密码至少需要 8 位。'
|
||||
return
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
passwordError.value = '两次输入的密码不一致。'
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const nextDisabled = !user.is_disabled
|
||||
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||||
await apiRequest<ActionResponse>(`/admin/users/${user.id}/password`, {
|
||||
method: 'PATCH',
|
||||
body: { is_disabled: nextDisabled },
|
||||
body: { new_password: newPassword.value },
|
||||
})
|
||||
users.value = users.value.map(item => item.id === user.id ? result.user : item)
|
||||
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||
passwordTarget.value = null
|
||||
message.success('密码已重置;该用户需要重新登录')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户状态更新失败')
|
||||
loadError.value = text
|
||||
message.error(text)
|
||||
passwordError.value = getErrorMessage(error, '密码重置失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
@@ -877,7 +873,9 @@ onMounted(loadAdminData)
|
||||
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-11 w-13 object-cover" />
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold text-slate-900">{{ item.cloudTypeName }}</span>
|
||||
<span class="mt-0.5 block truncate text-xs text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</span>
|
||||
<span class="mt-0.5 block truncate text-xs text-slate-500">
|
||||
{{ item.username }} · {{ formatCoordinate(item.latitude) }}, {{ formatCoordinate(item.longitude) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="hidden truncate text-xs text-slate-500 sm:block">{{ formatDateTime(item.created_at) }}</span>
|
||||
<span class="border px-2 py-1 text-xs font-medium" :class="statusMeta[item.status].chip">
|
||||
@@ -985,8 +983,8 @@ onMounted(loadAdminData)
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-slate-500">内容编号 {{ activeReviewImage.id }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 whitespace-nowrap border px-2 py-1 text-xs font-medium" :class="rarityMeta[activeReviewImage.cloudTypeRarity].chip">
|
||||
{{ rarityMeta[activeReviewImage.cloudTypeRarity].label }}
|
||||
<span class="shrink-0 whitespace-nowrap border border-sky-200 bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700">
|
||||
稀有度 {{ activeReviewImage.cloudTypeRarity }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -1048,10 +1046,6 @@ onMounted(loadAdminData)
|
||||
<dt class="text-xs font-medium text-slate-400">上传时间</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.created_at) }}</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-xs font-medium text-slate-400">位置名称</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-slate-800">{{ activeReviewImage.location_name || '未填写位置名称' }}</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-xs font-medium text-slate-400">模糊化坐标</dt>
|
||||
<dd class="mt-1 font-mono text-xs leading-5 text-slate-700">
|
||||
@@ -1109,26 +1103,39 @@ onMounted(loadAdminData)
|
||||
|
||||
<section v-else-if="activeTab === 'users'">
|
||||
<div class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
||||
<div class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<div class="hidden grid-cols-[minmax(0,1.1fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,0.9fr)_minmax(0,1.5fr)] gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 lg:grid">
|
||||
<span>用户</span>
|
||||
<span>角色</span>
|
||||
<span>状态</span>
|
||||
<span>注册时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div v-for="user in users" :key="user.id" class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] items-center gap-3 border-b border-slate-100 px-4 py-4 last:border-b-0">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="grid gap-4 border-b border-slate-100 px-4 py-4 last:border-b-0 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,0.9fr)_minmax(0,1.5fr)] lg:items-center lg:gap-3"
|
||||
>
|
||||
<div class="min-w-0 sm:col-span-2 lg:col-span-1">
|
||||
<p class="truncate font-medium text-slate-950">{{ user.username }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-400">{{ user.id }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-400">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 lg:block">
|
||||
<span class="text-xs font-medium text-slate-400 lg:hidden">角色</span>
|
||||
<NTag size="small" :bordered="false" :type="user.role === 'admin' ? 'success' : 'default'">
|
||||
{{ user.role === 'admin' ? '管理员' : '用户' }}
|
||||
</NTag>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 lg:block">
|
||||
<span class="text-xs font-medium text-slate-400 lg:hidden">状态</span>
|
||||
<NTag size="small" :bordered="false" :type="user.is_disabled ? 'error' : 'success'">
|
||||
{{ user.is_disabled ? '已禁用' : '正常' }}
|
||||
</NTag>
|
||||
<span class="text-sm text-slate-500">{{ formatDateTime(user.created_at) }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-sm text-slate-500 lg:block">
|
||||
<span class="text-xs font-medium text-slate-400 lg:hidden">注册时间</span>
|
||||
<span>{{ formatDateTime(user.created_at) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:col-span-2 lg:col-span-1">
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
@@ -1146,12 +1153,14 @@ onMounted(loadAdminData)
|
||||
type="default"
|
||||
secondary
|
||||
strong
|
||||
:class="user.is_disabled ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--danger'"
|
||||
class="oc-panel-button oc-panel-button--neutral"
|
||||
:disabled="user.id === authStore.user?.id"
|
||||
:loading="actionLoading"
|
||||
@click="toggleUserDisabled(user)"
|
||||
:title="user.id === authStore.user?.id ? '请在账号设置中修改自己的密码' : '设置新密码'"
|
||||
@click="openPasswordReset(user)"
|
||||
>
|
||||
{{ user.is_disabled ? '恢复' : '禁用' }}
|
||||
<template #icon><NIcon><Key /></NIcon></template>
|
||||
重置密码
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1162,7 +1171,7 @@ onMounted(loadAdminData)
|
||||
<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>
|
||||
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
||||
<p class="oc-field-help">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
||||
<p class="oc-field-help">共 {{ images.length }} 张图片,可调整审核状态或删除。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select
|
||||
@@ -1205,26 +1214,6 @@ onMounted(loadAdminData)
|
||||
>
|
||||
批量拒绝 {{ selectedManagedCount }}
|
||||
</NButton>
|
||||
<NButton
|
||||
secondary
|
||||
strong
|
||||
type="default"
|
||||
class="oc-panel-button oc-panel-button--amber"
|
||||
:loading="actionLoading"
|
||||
@click="updateImageVisibility(Array.from(selectedImageIds), true)"
|
||||
>
|
||||
批量隐藏 {{ selectedManagedCount }}
|
||||
</NButton>
|
||||
<NButton
|
||||
secondary
|
||||
strong
|
||||
type="default"
|
||||
class="oc-panel-button oc-panel-button--teal"
|
||||
:loading="actionLoading"
|
||||
@click="updateImageVisibility(Array.from(selectedImageIds), false)"
|
||||
>
|
||||
批量公开 {{ selectedManagedCount }}
|
||||
</NButton>
|
||||
<NButton
|
||||
secondary
|
||||
strong
|
||||
@@ -1248,7 +1237,9 @@ onMounted(loadAdminData)
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</p>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">
|
||||
{{ item.username }} · {{ formatCoordinate(item.latitude) }}, {{ formatCoordinate(item.longitude) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
||||
@@ -1266,15 +1257,6 @@ onMounted(loadAdminData)
|
||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
|
||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
|
||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--amber" :loading="actionLoading" @click="toggleImageVisibility(item)">
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<Eye v-if="item.is_hidden" />
|
||||
<EyeOff v-else />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ item.is_hidden ? '公开' : '隐藏' }}
|
||||
</NButton>
|
||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="deleteImage(item)">
|
||||
<template #icon><NIcon><Trash /></NIcon></template>
|
||||
删除
|
||||
@@ -1292,6 +1274,71 @@ onMounted(loadAdminData)
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="passwordTarget"
|
||||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
||||
@click="closePasswordReset"
|
||||
>
|
||||
<form class="oc-modal-shell w-full max-w-lg" @click.stop @submit.prevent="resetUserPassword">
|
||||
<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 class="min-w-0">
|
||||
<p class="oc-section-label">Admin Credential</p>
|
||||
<h3 class="mt-2 text-xl font-semibold text-slate-900">重置用户密码</h3>
|
||||
<p class="oc-field-help truncate">@{{ passwordTarget.username }} · {{ passwordTarget.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="oc-icon-button"
|
||||
aria-label="关闭密码重置窗口"
|
||||
title="关闭密码重置窗口"
|
||||
@click="closePasswordReset"
|
||||
>
|
||||
<NIcon size="20"><X /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 px-5 py-5 sm:px-6">
|
||||
<NAlert v-if="passwordError" type="error" :show-icon="false" :bordered="false">
|
||||
{{ passwordError }}
|
||||
</NAlert>
|
||||
<div>
|
||||
<label class="oc-field-label">新密码 <span class="oc-field-required">*</span></label>
|
||||
<NInput
|
||||
v-model:value="newPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="至少 8 位"
|
||||
:input-props="{ autocomplete: 'new-password', name: 'admin-new-password', required: true }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="oc-field-label">确认新密码 <span class="oc-field-required">*</span></label>
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="再次输入新密码"
|
||||
:input-props="{ autocomplete: 'new-password', name: 'admin-confirm-new-password', required: true }"
|
||||
/>
|
||||
</div>
|
||||
<p class="border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs leading-5 text-amber-800">
|
||||
保存后,该用户所有设备上的现有登录状态都会立即失效。
|
||||
</p>
|
||||
</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="closePasswordReset">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton attr-type="submit" type="default" secondary strong class="oc-panel-button oc-panel-button--amber" :loading="actionLoading">
|
||||
确认重置
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedImage"
|
||||
:open="!!selectedImage"
|
||||
@@ -1300,8 +1347,8 @@ onMounted(loadAdminData)
|
||||
:image-alt="selectedImage.cloudTypeName"
|
||||
:title="selectedImage.cloudTypeName"
|
||||
:subtitle="`上传者:${selectedImage.username}`"
|
||||
:badge-label="rarityMeta[selectedImage.cloudTypeRarity].label"
|
||||
:badge-class="rarityMeta[selectedImage.cloudTypeRarity].chip"
|
||||
:badge-label="`稀有度 ${selectedImage.cloudTypeRarity}`"
|
||||
badge-class="border-sky-200 bg-sky-100 text-sky-700"
|
||||
@close="selectedImage = null"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
@@ -1336,7 +1383,6 @@ onMounted(loadAdminData)
|
||||
<MiniLocationMap
|
||||
:latitude="selectedImage.latitude"
|
||||
:longitude="selectedImage.longitude"
|
||||
:location-name="selectedImage.location_name"
|
||||
/>
|
||||
</ImageDetailModal>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -15,13 +16,12 @@ const error = ref('')
|
||||
const loading = ref(false)
|
||||
const registered = ref(false)
|
||||
const emailDelivered = ref(true)
|
||||
|
||||
const canResend = computed(() =>
|
||||
error.value.includes('重发验证') || error.value.includes('已被注册,请使用重发'),
|
||||
)
|
||||
const registerConflict = ref(false)
|
||||
const canResend = computed(() => registerConflict.value)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
registerConflict.value = false
|
||||
const trimmedName = name.value.trim()
|
||||
const trimmedEmail = email.value.trim()
|
||||
|
||||
@@ -53,7 +53,12 @@ async function handleRegister() {
|
||||
emailDelivered.value = response.email_sent
|
||||
registered.value = true
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
registerConflict.value = true
|
||||
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
|
||||
} else {
|
||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user