feat: add admin console and account settings
Implement the admin dashboard with review, user, and image management workflows. Add profile settings, password reset, pending upload defaults, community placeholder routing, Vercel SPA rewrites, refreshed header styling, and the OpenCloud color-system skill.
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: opencloud-color-system
|
||||||
|
description: "Use when modifying OpenCloud UI colors, visual hierarchy, Tailwind classes, buttons, cards, headers, gradients, status chips, or page-level visual design. Ensures the project keeps its current fresh sky/teal/white visual language and avoids inconsistent dark or purple defaults."
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenCloud Color System
|
||||||
|
|
||||||
|
Use this skill whenever changing OpenCloud visual design, component styling, or Tailwind color classes.
|
||||||
|
|
||||||
|
## Visual Direction
|
||||||
|
|
||||||
|
OpenCloud should feel like a clean sky atlas: airy, fresh, bright, and observational.
|
||||||
|
|
||||||
|
- Base surfaces: `white`, `slate-50`, `sky-50`, soft translucent white (`bg-white/80`, `bg-white/88`).
|
||||||
|
- Main accent: teal/cyan for navigation, identity, account surfaces.
|
||||||
|
- Secondary accent: sky blue for upload and action highlights.
|
||||||
|
- Supporting accent: amber only for rarity, badges, pending/review states.
|
||||||
|
- Danger accent: rose/red only for destructive or rejected states.
|
||||||
|
- Avoid purple as a default accent.
|
||||||
|
- Avoid black selected states except for text or rare high-contrast badges.
|
||||||
|
|
||||||
|
## Core Palette
|
||||||
|
|
||||||
|
Use these Tailwind families first:
|
||||||
|
|
||||||
|
- `slate`: text, borders, neutral panels.
|
||||||
|
- `sky`: page gradients, upload/action emphasis, map/cloud atmosphere.
|
||||||
|
- `teal`: navigation selected states, account identity, calm success-adjacent UI.
|
||||||
|
- `cyan`: subtle sky gradients and avatar/logo surfaces.
|
||||||
|
- `emerald`: explicit success/approved state only.
|
||||||
|
- `amber`: pending/review/uncommon rarity.
|
||||||
|
- `rose`: rejected/destructive/rare rarity.
|
||||||
|
|
||||||
|
Preferred combinations:
|
||||||
|
|
||||||
|
- Page hero: `bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]`
|
||||||
|
- Soft app page: `bg-[linear-gradient(180deg,#f0fdfa_0%,#f8fafc_100%)]`
|
||||||
|
- Brand chip/avatar: `bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)]`
|
||||||
|
- Selected nav: `bg-teal-100 text-teal-800 ring-1 ring-teal-200`
|
||||||
|
- Upload button: `border-sky-200 bg-sky-100 text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)]`
|
||||||
|
|
||||||
|
## Component Rules
|
||||||
|
|
||||||
|
- Header navigation selected state should stay teal, not black.
|
||||||
|
- Upload action should stay visually distinct from nav, preferably sky blue.
|
||||||
|
- Username/account entry should be light and calm: white/teal, subtle border, subtle shadow.
|
||||||
|
- Cards should generally use `bg-white`, `border-slate-200`, and light offset shadows.
|
||||||
|
- Use gradients for page introductions instead of generic rounded white rectangles.
|
||||||
|
- When adding hover states, prefer light fills (`hover:bg-teal-50`, `hover:bg-sky-50`) over dark inversion.
|
||||||
|
|
||||||
|
## Status Colors
|
||||||
|
|
||||||
|
- Approved/success: `emerald-100`, `emerald-700`.
|
||||||
|
- Pending/review: `amber-100`, `amber-700`.
|
||||||
|
- Rejected/destructive: `rose-100`, `rose-700` or Naive UI `type="error"`.
|
||||||
|
- Hidden/private/neutral: `slate-100`, `slate-600`.
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Do not use purple as a fallback visual direction.
|
||||||
|
- Do not use `bg-slate-900 text-white` for ordinary selected navigation.
|
||||||
|
- Do not introduce dark-mode-heavy sections unless the surrounding page already uses that language.
|
||||||
|
- Do not overuse rounded rectangles; the project has been intentionally moving toward sharper, atlas-like panels.
|
||||||
|
- Do not mix random accent colors within the same feature. Pick one accent family and keep it consistent.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After color/style changes, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vue-tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run build` if the change touches routes, imported components, or package dependencies.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
import { NButton, NSpace } from 'naive-ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ const isMapRoute = computed(() => route.name === 'map')
|
|||||||
|
|
||||||
const headerHidden = ref(false)
|
const headerHidden = ref(false)
|
||||||
const headerPinnedOpen = ref(false)
|
const headerPinnedOpen = ref(false)
|
||||||
|
const activeNavClass = 'bg-teal-100 text-teal-800 ring-1 ring-teal-200'
|
||||||
|
const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
||||||
|
|
||||||
let lastScrollY = 0
|
let lastScrollY = 0
|
||||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||||
@@ -111,24 +113,31 @@ onMounted(() => {
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
to="/"
|
to="/"
|
||||||
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'map' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
地图
|
地图
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/encyclopedia"
|
to="/community"
|
||||||
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
图鉴
|
社区
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/gallery"
|
to="/gallery"
|
||||||
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'gallery' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
画廊
|
画廊
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/encyclopedia"
|
||||||
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
图鉴
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
||||||
@@ -137,9 +146,12 @@ onMounted(() => {
|
|||||||
to="/upload"
|
to="/upload"
|
||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<NButton type="primary" strong size="small" class="md:!h-10 md:!px-4 md:!text-sm">
|
<span
|
||||||
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium uppercase tracking-[0.12em] text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50 hover:text-sky-900 md:h-10 md:px-4"
|
||||||
|
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
||||||
|
>
|
||||||
上传
|
上传
|
||||||
</NButton>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<template v-if="authStore.isLoggedIn">
|
<template v-if="authStore.isLoggedIn">
|
||||||
@@ -147,9 +159,12 @@ onMounted(() => {
|
|||||||
to="/profile"
|
to="/profile"
|
||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<NTag type="success" bordered size="small" class="max-w-[7rem] truncate md:max-w-none">
|
<span
|
||||||
{{ authStore.profile?.username }}
|
class="inline-flex h-8 max-w-[7.5rem] items-center border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-colors hover:border-teal-200 hover:bg-teal-50 md:h-10 md:max-w-none"
|
||||||
</NTag>
|
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'bg-teal-50 ring-1 ring-teal-200' : ''"
|
||||||
|
>
|
||||||
|
<span class="truncate">@{{ authStore.profile?.username }}</span>
|
||||||
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<NButton
|
<NButton
|
||||||
quaternary
|
quaternary
|
||||||
@@ -182,29 +197,36 @@ onMounted(() => {
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
to="/"
|
to="/"
|
||||||
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'map' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
地图
|
地图
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/encyclopedia"
|
to="/community"
|
||||||
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
图鉴
|
社区
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/gallery"
|
to="/gallery"
|
||||||
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'gallery' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
画廊
|
画廊
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/encyclopedia"
|
||||||
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
图鉴
|
||||||
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isLoggedIn"
|
v-if="authStore.isLoggedIn"
|
||||||
to="/profile"
|
to="/profile"
|
||||||
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'profile' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
主页
|
主页
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ export function useUpload() {
|
|||||||
location_name: item.locationName || null,
|
location_name: item.locationName || null,
|
||||||
description: item.description.trim() || null,
|
description: item.description.trim() || null,
|
||||||
captured_at: item.capturedAt,
|
captured_at: item.capturedAt,
|
||||||
status: 'approved',
|
status: 'pending',
|
||||||
is_hidden: item.isHidden,
|
is_hidden: item.isHidden,
|
||||||
})
|
})
|
||||||
.select('id,cloud_type_id')
|
.select('id,cloud_type_id')
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ app.use(pinia)
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (window.location.pathname === '/auth/confirm') {
|
if (['/auth/confirm', '/auth/reset-password'].includes(window.location.pathname)) {
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const router = createRouter({
|
|||||||
name: 'auth-confirm',
|
name: 'auth-confirm',
|
||||||
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/reset-password',
|
||||||
|
name: 'auth-reset-password',
|
||||||
|
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/upload',
|
path: '/upload',
|
||||||
name: 'upload',
|
name: 'upload',
|
||||||
@@ -45,12 +50,23 @@ const router = createRouter({
|
|||||||
name: 'gallery',
|
name: 'gallery',
|
||||||
component: () => import('@/views/gallery/GalleryView.vue'),
|
component: () => import('@/views/gallery/GalleryView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/community',
|
||||||
|
name: 'community',
|
||||||
|
component: () => import('@/views/community/CommunityView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
name: 'profile',
|
name: 'profile',
|
||||||
component: () => import('@/views/profile/ProfileView.vue'),
|
component: () => import('@/views/profile/ProfileView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/profile/settings',
|
||||||
|
name: 'profile-settings',
|
||||||
|
component: () => import('@/views/profile/ProfileSettingsView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/profile/:id',
|
path: '/profile/:id',
|
||||||
name: 'user-profile',
|
name: 'user-profile',
|
||||||
|
|||||||
+69
-1
@@ -24,6 +24,24 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
|
||||||
|
let query = supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id')
|
||||||
|
.eq('username', username)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (currentUserId) {
|
||||||
|
query = query.neq('id', currentUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw error
|
||||||
|
if (data?.length) {
|
||||||
|
throw new Error('这个昵称已经被使用,请换一个。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -40,6 +58,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function register(email: string, password: string, username: string) {
|
async function register(email: string, password: string, username: string) {
|
||||||
|
await ensureUsernameAvailable(username)
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -66,6 +86,40 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
profile.value = null
|
profile.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateUsername(username: string) {
|
||||||
|
if (!user.value) throw new Error('请先登录。')
|
||||||
|
|
||||||
|
await ensureUsernameAvailable(username, user.value.id)
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ username })
|
||||||
|
.eq('id', user.value.id)
|
||||||
|
.select('*')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === '23505') {
|
||||||
|
throw new Error('这个昵称已经被使用,请换一个。')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.value = data as Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(password: string) {
|
||||||
|
const { error } = await supabase.auth.updateUser({ password })
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPasswordReset(email: string) {
|
||||||
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
const { data: { session } } = await supabase.auth.getSession()
|
const { data: { session } } = await supabase.auth.getSession()
|
||||||
user.value = session?.user ?? null
|
user.value = session?.user ?? null
|
||||||
@@ -79,5 +133,19 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, profile, loading, isLoggedIn, isAdmin, login, register, logout, initialize }
|
return {
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
loading,
|
||||||
|
isLoggedIn,
|
||||||
|
isAdmin,
|
||||||
|
fetchProfile,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateUsername,
|
||||||
|
updatePassword,
|
||||||
|
sendPasswordReset,
|
||||||
|
initialize,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,777 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
||||||
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useProfileStore } from '@/stores/profile'
|
||||||
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
|
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
|
||||||
|
type CloudStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
type ImageFilter = 'all' | CloudStatus
|
||||||
|
|
||||||
|
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']
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
users: number
|
||||||
|
images: number
|
||||||
|
todayUploads: number
|
||||||
|
pending: number
|
||||||
|
approved: number
|
||||||
|
rejected: number
|
||||||
|
hidden: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const activeTab = ref<AdminTab>('dashboard')
|
||||||
|
const loading = ref(true)
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const dashboardStats = ref<DashboardStats>({
|
||||||
|
users: 0,
|
||||||
|
images: 0,
|
||||||
|
todayUploads: 0,
|
||||||
|
pending: 0,
|
||||||
|
approved: 0,
|
||||||
|
rejected: 0,
|
||||||
|
hidden: 0,
|
||||||
|
})
|
||||||
|
const users = ref<Profile[]>([])
|
||||||
|
const images = ref<AdminCloud[]>([])
|
||||||
|
const selectedReviewIds = 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 statusMeta = {
|
||||||
|
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
||||||
|
approved: { label: '已通过', chip: 'border-emerald-200 bg-emerald-100 text-emerald-700' },
|
||||||
|
rejected: { label: '已拒绝', chip: 'border-rose-200 bg-rose-100 text-rose-700' },
|
||||||
|
} satisfies Record<CloudStatus, { label: string; chip: string }>
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'dashboard', label: '数据看板' },
|
||||||
|
{ key: 'review', label: '内容审核' },
|
||||||
|
{ key: 'users', label: '用户管理' },
|
||||||
|
{ key: 'images', label: '图片管理' },
|
||||||
|
] satisfies Array<{ key: AdminTab; label: string }>
|
||||||
|
|
||||||
|
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
|
||||||
|
const filteredImages = computed(() => {
|
||||||
|
if (imageFilter.value === 'all') return images.value
|
||||||
|
return images.value.filter(item => item.status === imageFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const cloudTypeStats = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const item of images.value) {
|
||||||
|
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
|
||||||
|
}
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 8)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
|
||||||
|
|
||||||
|
function readJoinedOne(value: unknown) {
|
||||||
|
const rows = Array.isArray(value) ? value : value ? [value] : []
|
||||||
|
return rows[0] as Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
|
||||||
|
const cloudType = readJoinedOne(row.cloud_types)
|
||||||
|
const profile = readJoinedOne(row.profiles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
user_id: row.user_id as string,
|
||||||
|
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
|
||||||
|
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
|
||||||
|
image_url: row.image_url as string,
|
||||||
|
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||||
|
latitude: (row.latitude as number | null) ?? null,
|
||||||
|
longitude: (row.longitude as number | null) ?? null,
|
||||||
|
location_name: (row.location_name as string | null) ?? null,
|
||||||
|
description: (row.description as string | null) ?? null,
|
||||||
|
captured_at: (row.captured_at as string | null) ?? null,
|
||||||
|
created_at: row.created_at as string,
|
||||||
|
status: row.status as CloudStatus,
|
||||||
|
is_hidden: (row.is_hidden as boolean) ?? false,
|
||||||
|
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
|
||||||
|
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
|
||||||
|
username: (profile?.username as string) || '匿名用户',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIsoStart() {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countProfiles() {
|
||||||
|
const { count, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('*', { head: true, count: 'exact' })
|
||||||
|
if (error) throw error
|
||||||
|
return count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countClouds(filters: {
|
||||||
|
status?: CloudStatus
|
||||||
|
isHidden?: boolean
|
||||||
|
createdAfter?: string
|
||||||
|
} = {}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('clouds')
|
||||||
|
.select('*', { head: true, count: 'exact' })
|
||||||
|
|
||||||
|
if (filters.status) query = query.eq('status', filters.status)
|
||||||
|
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
|
||||||
|
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
|
||||||
|
|
||||||
|
const { count, error } = await query
|
||||||
|
if (error) throw error
|
||||||
|
return count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
const [
|
||||||
|
userCount,
|
||||||
|
imageCount,
|
||||||
|
todayUploads,
|
||||||
|
pendingCount,
|
||||||
|
approvedCount,
|
||||||
|
rejectedCount,
|
||||||
|
hiddenCount,
|
||||||
|
] = await Promise.all([
|
||||||
|
countProfiles(),
|
||||||
|
countClouds(),
|
||||||
|
countClouds({ createdAfter: todayIsoStart() }),
|
||||||
|
countClouds({ status: 'pending' }),
|
||||||
|
countClouds({ status: 'approved' }),
|
||||||
|
countClouds({ status: 'rejected' }),
|
||||||
|
countClouds({ isHidden: true }),
|
||||||
|
])
|
||||||
|
|
||||||
|
dashboardStats.value = {
|
||||||
|
users: userCount,
|
||||||
|
images: imageCount,
|
||||||
|
todayUploads,
|
||||||
|
pending: pendingCount,
|
||||||
|
approved: approvedCount,
|
||||||
|
rejected: rejectedCount,
|
||||||
|
hidden: hiddenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id,username,avatar_url,role,is_disabled,created_at')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(100)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
users.value = (data || []) as Profile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImages() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('clouds')
|
||||||
|
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(120)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
|
||||||
|
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminData() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
fetchStats(),
|
||||||
|
fetchUsers(),
|
||||||
|
fetchImages(),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedReviewIds(ids: Iterable<string>) {
|
||||||
|
selectedReviewIds.value = new Set(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReviewSelection(cloudId: string) {
|
||||||
|
const next = new Set(selectedReviewIds.value)
|
||||||
|
if (next.has(cloudId)) {
|
||||||
|
next.delete(cloudId)
|
||||||
|
} else {
|
||||||
|
next.add(cloudId)
|
||||||
|
}
|
||||||
|
setSelectedReviewIds(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllPendingSelection() {
|
||||||
|
if (selectedReviewIds.value.size === pendingImages.value.length) {
|
||||||
|
setSelectedReviewIds([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedReviewIds(pendingImages.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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||||
|
if (!ids.length) return
|
||||||
|
|
||||||
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('clouds')
|
||||||
|
.update({ status })
|
||||||
|
.in('id', ids)
|
||||||
|
.select('id')
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
if ((data || []).length !== ids.length) {
|
||||||
|
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||||
|
}
|
||||||
|
|
||||||
|
patchImages(ids, { status })
|
||||||
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
||||||
|
await fetchStats()
|
||||||
|
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : '审核操作失败'
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleImageVisibility(cloud: AdminCloud) {
|
||||||
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextHidden = !cloud.is_hidden
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('clouds')
|
||||||
|
.update({ is_hidden: nextHidden })
|
||||||
|
.eq('id', cloud.id)
|
||||||
|
.select('id')
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
if (!data?.length) {
|
||||||
|
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||||
|
}
|
||||||
|
|
||||||
|
patchImages([cloud.id], { is_hidden: nextHidden })
|
||||||
|
await fetchStats()
|
||||||
|
message.success(nextHidden ? '图片已设为隐藏' : '图片已恢复公开')
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : '可见性更新失败'
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImage(cloud: AdminCloud) {
|
||||||
|
const confirmed = window.confirm(`确定删除 ${cloud.cloudTypeName} 这张图片吗?删除后无法在页面中恢复。`)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await profileStore.deleteClouds(cloud.user_id, [cloud.id])
|
||||||
|
images.value = images.value.filter(item => item.id !== cloud.id)
|
||||||
|
if (selectedImage.value?.id === cloud.id) selectedImage.value = null
|
||||||
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => id !== cloud.id))
|
||||||
|
await fetchStats()
|
||||||
|
message.success('图片已删除')
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : '图片删除失败'
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||||
|
if (user.id === authStore.user?.id && role !== 'admin') {
|
||||||
|
message.warning('不能移除自己的管理员权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ role })
|
||||||
|
.eq('id', user.id)
|
||||||
|
.select('id,role')
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
if (!data?.length) {
|
||||||
|
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||||
|
}
|
||||||
|
|
||||||
|
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
|
||||||
|
message.success('用户角色已更新')
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : '用户角色更新失败'
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserDisabled(user: Profile) {
|
||||||
|
if (user.id === authStore.user?.id) {
|
||||||
|
message.warning('不能禁用当前登录的管理员账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextDisabled = !user.is_disabled
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ is_disabled: nextDisabled })
|
||||||
|
.eq('id', user.id)
|
||||||
|
.select('id,is_disabled')
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
if (!data?.length) {
|
||||||
|
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
|
||||||
|
}
|
||||||
|
|
||||||
|
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
|
||||||
|
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : '用户状态更新失败'
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAdminData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-5xl mx-auto px-4 py-12">
|
<div class="min-h-screen bg-[linear-gradient(180deg,#f8fafc_0%,#eef6ff_55%,#f8fafc_100%)]">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">🔧 管理后台</h1>
|
<section class="border-b border-slate-200 bg-white/82 backdrop-blur">
|
||||||
<p class="text-gray-500">管理后台开发中...</p>
|
<div class="mx-auto max-w-7xl px-4 py-10">
|
||||||
|
<div class="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
|
||||||
|
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NButton secondary strong :loading="loading" @click="loadAdminData">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><Refresh /></NIcon>
|
||||||
|
</template>
|
||||||
|
刷新数据
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-7xl px-4 py-8">
|
||||||
|
<NAlert
|
||||||
|
v-if="loadError"
|
||||||
|
class="mb-6"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="后台操作失败"
|
||||||
|
>
|
||||||
|
{{ loadError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<section v-if="loading" class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div v-for="n in 8" :key="n" class="border border-slate-200 bg-white p-5">
|
||||||
|
<NSkeleton class="h-4 w-1/2" />
|
||||||
|
<NSkeleton class="mt-4 h-8 w-2/3" />
|
||||||
|
<NSkeleton class="mt-3 h-3 w-full" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="grid gap-3 md:grid-cols-4">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
class="border px-5 py-4 text-left transition-colors"
|
||||||
|
:class="activeTab === tab.key ? 'border-sky-500 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-400'"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold">{{ tab.label }}</span>
|
||||||
|
<span v-if="tab.key === 'review'" class="ml-2 text-xs text-amber-600">{{ dashboardStats.pending }}</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'dashboard'" class="mt-6 space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div class="border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-sm text-slate-500">总用户数</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-slate-950">{{ dashboardStats.users }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-sm text-slate-500">图片总数</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-slate-950">{{ dashboardStats.images }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-sm text-slate-500">今日上传</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-slate-950">{{ dashboardStats.todayUploads }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-amber-200 bg-amber-50 p-5">
|
||||||
|
<p class="text-sm text-amber-700">待审核</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-amber-900">{{ dashboardStats.pending }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
|
||||||
|
<div class="border border-slate-200 bg-white p-6">
|
||||||
|
<h2 class="text-xl font-bold text-slate-950">审核状态</h2>
|
||||||
|
<div class="mt-5 grid gap-3">
|
||||||
|
<div class="flex items-center justify-between border border-slate-100 bg-slate-50 px-4 py-3">
|
||||||
|
<span class="text-sm text-slate-600">已通过</span>
|
||||||
|
<span class="font-semibold text-emerald-700">{{ dashboardStats.approved }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border border-slate-100 bg-slate-50 px-4 py-3">
|
||||||
|
<span class="text-sm text-slate-600">已拒绝</span>
|
||||||
|
<span class="font-semibold text-rose-700">{{ dashboardStats.rejected }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border border-slate-100 bg-slate-50 px-4 py-3">
|
||||||
|
<span class="text-sm text-slate-600">隐藏图片</span>
|
||||||
|
<span class="font-semibold text-slate-900">{{ dashboardStats.hidden }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-slate-200 bg-white p-6">
|
||||||
|
<h2 class="text-xl font-bold text-slate-950">云型分布</h2>
|
||||||
|
<div v-if="cloudTypeStats.length" class="mt-5 space-y-3">
|
||||||
|
<div v-for="item in cloudTypeStats" :key="item.name">
|
||||||
|
<div class="mb-1 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-slate-600">{{ item.name }}</span>
|
||||||
|
<span class="font-medium text-slate-900">{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 bg-slate-100">
|
||||||
|
<div
|
||||||
|
class="h-full bg-sky-500"
|
||||||
|
:style="{ width: `${Math.max(8, item.count / Math.max(1, dashboardStats.images) * 100)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NEmpty v-else class="py-6" description="暂无图片数据" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="activeTab === 'review'" class="mt-6">
|
||||||
|
<div class="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="mt-1 text-sm text-slate-500">共 {{ pendingImages.length }} 张待处理图片。</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<NButton secondary strong @click="toggleAllPendingSelection">
|
||||||
|
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="success"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:disabled="!selectedReviewCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateCloudStatus(Array.from(selectedReviewIds), 'approved')"
|
||||||
|
>
|
||||||
|
批量通过 {{ selectedReviewCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="error"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:disabled="!selectedReviewCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateCloudStatus(Array.from(selectedReviewIds), 'rejected')"
|
||||||
|
>
|
||||||
|
批量拒绝 {{ selectedReviewCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pendingImages.length" class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<article v-for="item in pendingImages" :key="item.id" class="grid gap-4 border border-slate-200 bg-white p-4 md:grid-cols-[180px_minmax(0,1fr)]">
|
||||||
|
<button type="button" class="overflow-hidden bg-slate-100" @click="selectedImage = item">
|
||||||
|
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-44 w-full object-cover transition-transform hover:scale-105" />
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">上传者:{{ item.username }}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-5 w-5"
|
||||||
|
:checked="selectedReviewIds.has(item.id)"
|
||||||
|
@change="toggleReviewSelection(item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">{{ item.location_name || '未填写位置' }} · {{ formatDateTime(item.created_at) }}</p>
|
||||||
|
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">{{ item.description || '没有图片说明。' }}</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<NButton size="small" type="success" secondary strong :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">
|
||||||
|
<template #icon><NIcon><Check /></NIcon></template>
|
||||||
|
通过
|
||||||
|
</NButton>
|
||||||
|
<NButton size="small" type="error" secondary strong :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">
|
||||||
|
<template #icon><NIcon><X /></NIcon></template>
|
||||||
|
拒绝
|
||||||
|
</NButton>
|
||||||
|
<NButton size="small" secondary strong @click="selectedImage = item">查看大图</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
||||||
|
<NEmpty description="当前没有待审核图片" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="activeTab === 'users'" class="mt-6">
|
||||||
|
<div class="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">
|
||||||
|
<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">
|
||||||
|
<p class="truncate font-medium text-slate-950">{{ user.username }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-slate-400">{{ user.id }}</p>
|
||||||
|
</div>
|
||||||
|
<NTag size="small" :bordered="false" :type="user.role === 'admin' ? 'success' : 'default'">
|
||||||
|
{{ user.role === 'admin' ? '管理员' : '用户' }}
|
||||||
|
</NTag>
|
||||||
|
<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">
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:disabled="user.id === authStore.user?.id && user.role === 'admin'"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateUserRole(user, user.role === 'admin' ? 'user' : 'admin')"
|
||||||
|
>
|
||||||
|
{{ user.role === 'admin' ? '设为用户' : '设为管理员' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
:type="user.is_disabled ? 'success' : 'error'"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:disabled="user.id === authStore.user?.id"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="toggleUserDisabled(user)"
|
||||||
|
>
|
||||||
|
{{ user.is_disabled ? '恢复' : '禁用' }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="mt-6">
|
||||||
|
<div class="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="mt-1 text-sm text-slate-500">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="imageFilter"
|
||||||
|
class="border border-slate-300 bg-white px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="pending">待审核</option>
|
||||||
|
<option value="approved">已通过</option>
|
||||||
|
<option value="rejected">已拒绝</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<article v-for="item in filteredImages" :key="item.id" class="overflow-hidden border border-slate-200 bg-white">
|
||||||
|
<button type="button" class="block w-full overflow-hidden bg-slate-100" @click="selectedImage = item">
|
||||||
|
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-52 w-full object-cover transition-transform hover:scale-105" />
|
||||||
|
</button>
|
||||||
|
<div class="p-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
||||||
|
{{ statusMeta[item.status].label }}
|
||||||
|
</NTag>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<NButton size="small" secondary strong @click="selectedImage = item">查看</NButton>
|
||||||
|
<NButton size="small" type="success" secondary strong :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
|
||||||
|
<NButton size="small" type="error" secondary strong :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
|
||||||
|
<NButton size="small" secondary strong :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="error" secondary strong :loading="actionLoading" @click="deleteImage(item)">
|
||||||
|
<template #icon><NIcon><Trash /></NIcon></template>
|
||||||
|
删除
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
||||||
|
<NEmpty description="没有符合筛选条件的图片" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedImage"
|
||||||
|
:open="!!selectedImage"
|
||||||
|
:image-url="selectedImage.image_url"
|
||||||
|
:thumbnail-url="selectedImage.thumbnail_url"
|
||||||
|
:image-alt="selectedImage.cloudTypeName"
|
||||||
|
:title="selectedImage.cloudTypeName"
|
||||||
|
:subtitle="`上传者:${selectedImage.username}`"
|
||||||
|
:badge-label="rarityMeta[selectedImage.cloudTypeRarity].label"
|
||||||
|
:badge-class="rarityMeta[selectedImage.cloudTypeRarity].chip"
|
||||||
|
@close="selectedImage = null"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedImage.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedImage.captured_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">状态</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
{{ statusMeta[selectedImage.status].label }} / {{ selectedImage.is_hidden ? '隐藏' : '公开' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(selectedImage.latitude) }} / 经度 {{ formatCoordinate(selectedImage.longitude) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ selectedImage.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ImageDetailModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ const route = useRoute()
|
|||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const resetMessage = ref('')
|
||||||
|
const resetMode = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const resetLoading = ref(false)
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -26,6 +29,26 @@ async function handleLogin() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSendResetEmail() {
|
||||||
|
error.value = ''
|
||||||
|
resetMessage.value = ''
|
||||||
|
|
||||||
|
if (!email.value) {
|
||||||
|
error.value = '请输入需要重置密码的邮箱。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLoading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.sendPasswordReset(email.value)
|
||||||
|
resetMessage.value = '重置密码邮件已发送,请查收邮箱。'
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
resetLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -61,7 +84,7 @@ async function handleLogin() {
|
|||||||
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NForm @submit.prevent="handleLogin">
|
<NForm @submit.prevent="resetMode ? handleSendResetEmail() : handleLogin()">
|
||||||
<NFormItem label="邮箱">
|
<NFormItem label="邮箱">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="email"
|
v-model:value="email"
|
||||||
@@ -71,7 +94,7 @@ async function handleLogin() {
|
|||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="密码">
|
<NFormItem v-if="!resetMode" label="密码">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="password"
|
v-model:value="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -82,25 +105,47 @@ async function handleLogin() {
|
|||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
|
<p v-else class="mb-5 text-sm leading-6 text-slate-500">
|
||||||
|
输入注册邮箱,我们会发送一封密码重置邮件。点击邮件链接后即可设置新密码。
|
||||||
|
</p>
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert v-if="resetMessage" type="success" class="mb-4">
|
||||||
|
{{ resetMessage }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
<NButton
|
<NButton
|
||||||
attr-type="submit"
|
attr-type="submit"
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
size="large"
|
size="large"
|
||||||
:loading="loading"
|
:loading="resetMode ? resetLoading : loading"
|
||||||
>
|
>
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
<template v-if="resetMode">
|
||||||
|
{{ resetLoading ? '发送中...' : '发送重置邮件' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</template>
|
||||||
</NButton>
|
</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-slate-500">
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||||
没有账号?
|
<p>
|
||||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
没有账号?
|
||||||
</p>
|
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
||||||
|
@click="resetMode = !resetMode; error = ''; resetMessage = ''"
|
||||||
|
>
|
||||||
|
{{ resetMode ? '返回登录' : '忘记密码?' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,19 +47,33 @@ async function handleRegister() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
||||||
<section class="border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
<div>
|
||||||
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||||
加入天空探索者
|
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||||
<span class="block text-sky-700">建立你的云图档案</span>
|
加入天空探索者
|
||||||
</h1>
|
<span class="block text-sky-700">建立你的云图档案</span>
|
||||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
</h1>
|
||||||
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
</p>
|
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 flex gap-4">
|
||||||
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Review</div>
|
||||||
|
<div class="mt-2 text-2xl font-bold text-slate-900">Queue</div>
|
||||||
|
<div class="mt-1 text-sm text-slate-500">上传后进入审核队列</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Profile</div>
|
||||||
|
<div class="mt-2 text-2xl font-bold text-slate-900">Journal</div>
|
||||||
|
<div class="mt-1 text-sm text-slate-500">自动生成个人天空日志</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult
|
<NResult
|
||||||
v-if="emailSent"
|
v-if="emailSent"
|
||||||
status="success"
|
status="success"
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() => password.value.length >= 6 && password.value === confirmPassword.value)
|
||||||
|
|
||||||
|
function getRecoveryTokens() {
|
||||||
|
const hash = new URLSearchParams(window.location.hash.slice(1))
|
||||||
|
return {
|
||||||
|
accessToken: hash.get('access_token'),
|
||||||
|
refreshToken: hash.get('refresh_token'),
|
||||||
|
type: hash.get('type'),
|
||||||
|
error: hash.get('error'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (password.value.length < 6) {
|
||||||
|
error.value = '新密码至少需要 6 位。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = '两次输入的密码不一致。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken, refreshToken, type, error: recoveryError } = getRecoveryTokens()
|
||||||
|
if (recoveryError || type !== 'recovery' || !accessToken || !refreshToken) {
|
||||||
|
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resetClient = createClient(
|
||||||
|
import.meta.env.VITE_SUPABASE_URL,
|
||||||
|
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { error: sessionError } = await resetClient.auth.setSession({
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
if (sessionError) throw sessionError
|
||||||
|
|
||||||
|
const { error: updateError } = await resetClient.auth.updateUser({ password: password.value })
|
||||||
|
if (updateError) throw updateError
|
||||||
|
|
||||||
|
await resetClient.auth.signOut()
|
||||||
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
|
success.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<NResult
|
||||||
|
v-if="success"
|
||||||
|
status="success"
|
||||||
|
title="密码已重置"
|
||||||
|
description="现在可以使用新密码登录。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<NButton type="primary" @click="router.push('/login')">返回登录</NButton>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||||
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">请输入新的登录密码,提交后当前重置链接会失效。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleResetPassword">
|
||||||
|
<NFormItem label="新密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="确认新密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
保存新密码
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f0fdfa_0%,#f8fafc_100%)] px-4 py-16">
|
||||||
|
<section class="mx-auto max-w-4xl border border-slate-200 bg-white p-10 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
|
||||||
|
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
社区模块正在规划中。后续这里会承载话题讨论、观测活动和用户互动内容。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const usernameDraft = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmNewPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const usernameSaving = ref(false)
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
usernameDraft.value = authStore.profile?.username || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveUsername() {
|
||||||
|
const nextUsername = usernameDraft.value.trim()
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (!nextUsername) {
|
||||||
|
error.value = '昵称不能为空。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextUsername.length < 2) {
|
||||||
|
error.value = '昵称至少需要 2 个字符。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextUsername === authStore.profile?.username) {
|
||||||
|
success.value = '昵称没有变化。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameSaving.value = true
|
||||||
|
try {
|
||||||
|
await authStore.updateUsername(nextUsername)
|
||||||
|
success.value = '昵称已更新。'
|
||||||
|
message.success('昵称已更新')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '昵称更新失败'
|
||||||
|
} finally {
|
||||||
|
usernameSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (newPassword.value.length < 6) {
|
||||||
|
error.value = '新密码至少需要 6 位。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmNewPassword.value) {
|
||||||
|
error.value = '两次输入的新密码不一致。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordSaving.value = true
|
||||||
|
try {
|
||||||
|
await authStore.updatePassword(newPassword.value)
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmNewPassword.value = ''
|
||||||
|
success.value = '密码已更新。'
|
||||||
|
message.success('密码已更新')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '密码更新失败'
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<RouterLink to="/profile">
|
||||||
|
<NButton text type="primary">← 返回个人主页</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Account Settings</p>
|
||||||
|
<h1 class="mt-3 text-4xl font-bold text-slate-900">个人资料设置</h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
修改公开昵称和登录密码。昵称会显示在画廊、图鉴和地图中,且不能与其他用户重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6">
|
||||||
|
<NAlert
|
||||||
|
v-if="error"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
<NAlert
|
||||||
|
v-if="success"
|
||||||
|
type="success"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
{{ success }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NCard class="border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
用于公开展示你的上传记录。保存前会检查是否与其他用户重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<NInput
|
||||||
|
v-model:value="usernameDraft"
|
||||||
|
placeholder="输入新的昵称"
|
||||||
|
maxlength="32"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:loading="usernameSaving"
|
||||||
|
@click="saveUsername"
|
||||||
|
>
|
||||||
|
保存昵称
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard class="border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
修改后下次登录需要使用新密码。建议使用至少 6 位且不易猜测的密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<NInput
|
||||||
|
v-model:value="newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="新密码"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="确认新密码"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:loading="passwordSaving"
|
||||||
|
@click="savePassword"
|
||||||
|
>
|
||||||
|
保存密码
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -507,9 +507,23 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<h1 class="mt-3 text-4xl font-bold text-slate-900">
|
<h1 class="mt-3 text-4xl font-bold text-slate-900">
|
||||||
{{ pageTitle }}
|
{{ pageTitle }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-3 text-lg font-medium text-slate-700">
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
@{{ profileData?.username || '未知用户' }}
|
<p class="text-lg font-medium text-slate-700">
|
||||||
</p>
|
@{{ profileData?.username || '未知用户' }}
|
||||||
|
</p>
|
||||||
|
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center border border-slate-300 bg-white text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
|
||||||
|
title="个人资料设置"
|
||||||
|
aria-label="个人资料设置"
|
||||||
|
>
|
||||||
|
<NIcon size="17">
|
||||||
|
<Settings />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
{{ profileSubtitle }}
|
{{ profileSubtitle }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -218,15 +218,15 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||||
<div class="w-full max-w-5xl">
|
<div class="w-full max-w-5xl">
|
||||||
<NResult status="success" title="上传成功" class="border border-slate-200 bg-white shadow-sm">
|
<NResult status="success" title="已提交审核" class="border border-slate-200 bg-white shadow-sm">
|
||||||
<template #default>
|
<template #default>
|
||||||
<p class="text-slate-500">
|
<p class="text-slate-500">
|
||||||
<template v-if="unlockedBadges.length">
|
<template v-if="unlockedBadges.length">
|
||||||
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章。
|
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章。图片审核通过后会出现在画廊和地图中。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
这批云图已进入待审核队列,审核通过后会出现在画廊和地图中。
|
||||||
</template>
|
</template>
|
||||||
<!-- <template v-else>
|
|
||||||
这批云图已经进入你的收藏记录。
|
|
||||||
</template> -->
|
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user