feat(admin): 重构管理控制台布局并完善管理功能

This commit is contained in:
2026-10-01 10:31:06 +08:00
parent f31691c300
commit 86b6383251
11 changed files with 3008 additions and 2213 deletions
+523
View File
@@ -0,0 +1,523 @@
.admin-shell {
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
min-height: 100vh;
background: var(--oc-gradient-canvas);
transition: grid-template-columns 160ms ease;
}
.admin-shell--collapsed {
grid-template-columns: 76px minmax(0, 1fr);
}
.admin-sidebar {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
border-right: 1px solid var(--oc-color-border);
background: white;
}
.admin-brand {
display: flex;
align-items: center;
gap: 12px;
min-height: 92px;
padding: 20px;
}
.admin-brand--collapsed {
justify-content: center;
padding: 16px 0;
}
.admin-navigation {
padding: 0 12px;
}
.admin-nav-group {
margin: 20px 12px 8px;
color: var(--oc-color-text-disabled);
font-size: 11px;
letter-spacing: 0.1em;
}
.admin-nav-group:first-child {
margin-top: 0;
}
.admin-nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 10px 12px;
margin-bottom: 4px;
width: 100%;
border: 1px solid transparent;
color: var(--oc-color-text-muted);
font-size: 13px;
text-align: left;
transition: background 160ms ease;
}
.admin-nav-item:hover {
background: var(--oc-color-surface-muted);
color: var(--oc-color-text);
}
.admin-nav-item--active {
border-color: var(--color-teal-200);
background: var(--color-teal-50);
color: var(--color-teal-800);
font-weight: 600;
box-shadow: var(--oc-shadow-xs);
}
.admin-nav-item--active:hover {
background: var(--color-teal-50);
color: var(--color-teal-800);
}
.admin-nav-badge {
padding: 1px 5px;
border: 1px solid #fde68a;
background: #fffbeb;
color: #92400e;
font-size: 10px;
line-height: 16px;
font-variant-numeric: tabular-nums;
}
.admin-nav-badge--compact {
position: absolute;
right: -4px;
top: -2px;
}
.admin-workspace {
min-width: 0;
}
.admin-topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 68px;
padding: 0 32px;
border-bottom: 1px solid var(--oc-color-border);
background: rgb(255 255 255 / 96%);
}
.admin-mobile-toggle {
display: none;
}
.admin-account {
display: flex;
align-items: center;
gap: 10px;
padding: 4px;
}
.admin-user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
border: 1px solid var(--color-teal-200);
background: var(--color-teal-50);
color: var(--color-teal-800);
font-size: 13px;
font-weight: 600;
}
.admin-content {
width: 100%;
max-width: 1600px;
margin: 0 auto;
padding: 30px 32px;
}
.admin-page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 28px;
}
.admin-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.admin-stat {
min-width: 0;
padding: 20px;
border: 1px solid var(--oc-color-border);
background: white;
text-align: left;
transition: transform 160ms ease;
}
.admin-stat:hover {
transform: translate(-1px, -1px);
}
.admin-stat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
flex-shrink: 0;
border: 1px solid var(--oc-color-border);
background: var(--oc-color-surface-muted);
color: var(--oc-color-text-muted);
}
.admin-stat-icon--sky {
background: #f0f9ff;
color: #0369a1;
border-color: #bae6fd;
}
.admin-stat-icon--teal {
background: var(--color-teal-50);
color: var(--color-teal-700);
border-color: var(--color-teal-200);
}
.admin-stat-icon--amber {
background: #fffbeb;
color: #b45309;
border-color: #fde68a;
}
.admin-priority {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
border: 1px solid var(--oc-color-border);
background: white;
}
.admin-priority--pending {
border-color: #fde68a;
background: #fffdf7;
}
.admin-overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: 24px;
}
.admin-panel {
min-width: 0;
border: 1px solid var(--oc-color-border);
background: white;
}
.admin-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-bottom: 1px solid #f1f5f9;
}
.admin-panel-heading h2 {
color: var(--oc-color-text);
font-size: 15px;
font-weight: 600;
}
.admin-panel-heading p {
margin-top: 5px;
color: var(--oc-color-text-muted);
font-size: 12px;
line-height: 1.7;
}
.admin-chart {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 30px 28px 24px;
}
.admin-chart-column {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 10px;
}
.admin-chart-track {
position: relative;
width: 100%;
height: 156px;
background: repeating-linear-gradient(
to top,
transparent 0,
transparent 38px,
#f1f5f9 38px,
#f1f5f9 39px
);
}
.admin-chart-bar {
position: absolute;
bottom: 0;
left: 15%;
width: 70%;
border-top: 2px solid #7dd3fc;
background: #e0f2fe;
}
.admin-chart-bar--today {
border-color: var(--color-teal-400);
background: var(--color-teal-200);
}
.admin-recent-row {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
padding: 14px 20px;
border-bottom: 1px solid #f1f5f9;
text-align: left;
}
.admin-recent-row:last-child {
border-bottom: 0;
}
.admin-recent-row:hover {
background: var(--oc-color-surface-muted);
}
.admin-filters {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 16px;
padding: 20px;
}
.admin-filters > div {
flex: 1 1 144px;
min-width: 0;
}
.admin-filters > .admin-filter-search {
flex: 2 1 280px;
}
.admin-filters > .self-end {
flex: 0 0 auto;
}
.admin-filters .oc-field-control {
width: 100%;
height: 34px;
min-width: 0;
padding: 5px 10px;
font-size: 13px;
}
.admin-filters .oc-field-label {
margin-bottom: 6px;
font-size: 12px;
}
.admin-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
background: var(--oc-color-surface-muted);
}
.admin-sort {
max-width: 100%;
min-width: 0;
border: 1px solid var(--oc-color-border);
padding: 5px 8px;
color: var(--oc-color-text-secondary);
background: white;
font-size: 12px;
}
.admin-batch-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f0f9ff;
color: #075985;
border-bottom: 1px solid #bae6fd;
}
.admin-data-table {
min-width: 0;
width: 100%;
}
.admin-data-table .n-data-table-th {
font-size: 12px;
font-weight: 500;
}
.admin-data-table .n-data-table-td {
font-size: 13px;
}
.admin-thumbnail {
display: block;
width: 56px;
height: 40px;
border: 1px solid var(--oc-color-border);
background: #f0f9ff;
}
.admin-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-cloud-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
padding: 20px;
}
.admin-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 16px 20px;
border-top: 1px solid #f1f5f9;
}
.admin-form-modal.n-card {
width: min(540px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
overflow-y: auto;
border: 1px solid var(--oc-color-border);
box-shadow: var(--oc-shadow-xl);
}
.admin-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid var(--oc-color-border);
padding-top: 20px;
}
.admin-shell button:focus-visible,
.admin-shell a:focus-visible,
.admin-sort:focus-visible {
outline: 2px solid #38bdf8;
outline-offset: 3px;
}
.admin-shell input[type='checkbox'] {
accent-color: var(--oc-color-primary);
width: 15px;
height: 15px;
}
@media (min-width: 1800px) {
.admin-content {
padding-top: 40px;
}
}
@media (max-width: 1199px) {
.admin-content {
padding: 24px;
}
.admin-topbar {
padding: 0 24px;
}
.admin-stat-grid {
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-cloud-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 899px) {
.admin-shell,
.admin-shell--collapsed {
display: block;
}
.admin-sidebar,
.admin-desktop-toggle {
display: none;
}
.admin-mobile-toggle {
display: inline-flex;
}
}
@media (max-width: 639px) {
.admin-topbar {
height: 60px;
gap: 8px;
padding: 0 16px;
}
.admin-content {
padding: 24px 16px;
}
.admin-page-heading {
align-items: flex-start;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.admin-stat-grid {
gap: 12px;
}
.admin-stat {
padding: 14px;
}
.admin-stat-icon {
width: 30px;
height: 30px;
}
.admin-stat .oc-stat-label {
font-size: 11px;
letter-spacing: 0.04em;
}
.admin-stat .text-3xl {
font-size: 26px;
}
.admin-overview-grid {
grid-template-columns: minmax(0, 1fr);
}
.admin-priority {
align-items: flex-start;
flex-wrap: wrap;
padding: 16px;
}
.admin-priority > .n-button {
margin-left: 46px;
}
.admin-panel-heading {
align-items: flex-start;
flex-wrap: wrap;
padding: 16px;
}
.admin-filters {
gap: 12px;
padding: 16px;
}
.admin-filters > div {
flex-basis: calc(50% - 6px);
}
.admin-filters > .admin-filter-search {
flex-basis: 100%;
}
.admin-filters > .self-end {
flex-basis: 100%;
}
.admin-table-toolbar,
.admin-batch-toolbar {
padding: 12px 16px;
}
.admin-pagination {
padding: 16px;
}
.admin-cloud-grid {
grid-template-columns: minmax(0, 1fr);
padding: 16px;
}
.admin-chart {
gap: 8px;
padding: 24px 16px;
}
.admin-recent-row {
gap: 10px;
padding: 14px 16px;
}
}
@media (max-width: 639px) {
.admin-priority {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
}
.admin-priority > .n-button {
grid-column: 2;
margin-left: 0;
justify-self: start;
}
.admin-topbar .admin-breadcrumb-separator {
display: none;
}
}
+14 -1
View File
@@ -10,6 +10,7 @@ import type {
CloudTypeCreateResponse,
ReviewCloudsBody,
SetUserRoleBody,
CreateUserBody,
} from '@/types/api-contract'
export async function listUsers() {
@@ -25,16 +26,28 @@ export function listAdminClouds() {
})
}
export async function reviewClouds(ids: string[], reviewStatus: ReviewCloudsBody['review_status']) {
export async function reviewClouds(
ids: string[],
reviewStatus: ReviewCloudsBody['review_status'],
onBatchComplete?: (ids: string[]) => void,
) {
for (let offset = 0; offset < ids.length; offset += 100) {
const body: ReviewCloudsBody = {
cloud_ids: ids.slice(offset, offset + 100),
review_status: reviewStatus,
}
await apiRequest<MessageResponse>('/admin/clouds/review', { method: 'POST', body })
onBatchComplete?.(body.cloud_ids)
}
}
export function createUser(body: CreateUserBody) {
return apiRequest<{ message: string; user_id: string; email_sent: boolean }>('/admin/users', {
method: 'POST',
body,
})
}
export function deleteAdminCloud(id: string) {
return apiRequest<MessageResponse>(`/admin/clouds/${encodeURIComponent(id)}`, {
method: 'DELETE',
@@ -0,0 +1,545 @@
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import {
NButton,
NDataTable,
NEmpty,
NIcon,
NInput,
NPagination,
NTag,
type DataTableColumns,
} from 'naive-ui'
import { Download, LayoutGrid, List, Search } from '@vicons/tabler'
import type { CloudDetail } from '@/types/view-models'
import type { CloudTypeResponse } from '@/types/api-contract'
import {
exportCsv,
formatDateTime,
statusMeta,
visibilityLabel,
type ReviewStatus,
} from '@/features/admin/models'
import { useAdminViewport } from '@/features/admin/composables/useAdminViewport'
const compact = useAdminViewport()
const props = defineProps<{
clouds: CloudDetail[]
cloudTypes: CloudTypeResponse[]
review?: boolean
busy: boolean
owner?: string
}>()
const emit = defineEmits<{
preview: [cloud: CloudDetail]
review: [ids: string[], status: 'approved' | 'rejected']
delete: [clouds: CloudDetail[]]
clearOwner: []
}>()
const search = ref('')
const advancedFilters = ref(false)
const status = ref<ReviewStatus | ''>('')
const typeId = ref('')
const visibility = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const order = ref('newest')
const view = ref<'table' | 'grid'>(compact.value ? 'grid' : 'table')
const page = ref(1)
const pageSize = ref(20)
const selected = ref<string[]>([])
const filtered = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
const from = dateFrom.value ? new Date(`${dateFrom.value}T00:00:00`).getTime() : -Infinity
const to = dateTo.value ? new Date(`${dateTo.value}T23:59:59.999`).getTime() : Infinity
return props.clouds
.filter(cloud => {
return (
(!props.review || cloud.status === 'pending') &&
(!props.owner || cloud.user_id === props.owner) &&
(!status.value || cloud.status === status.value) &&
(!typeId.value || String(cloud.cloud_type_id) === typeId.value) &&
(!visibility.value ||
(visibility.value === 'hidden'
? cloud.is_hidden
: cloud.status === 'approved' && !cloud.is_hidden)) &&
Date.parse(cloud.created_at) >= from &&
Date.parse(cloud.created_at) <= to &&
(!query ||
[cloud.username, cloud.cloud_type_name, cloud.description, cloud.id].some(
value => value?.toLocaleLowerCase().includes(query),
))
)
})
.sort((a, b) =>
order.value === 'oldest'
? Date.parse(a.created_at) - Date.parse(b.created_at)
: Date.parse(b.created_at) - Date.parse(a.created_at),
)
})
const pageRows = computed(() =>
filtered.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value),
)
const selectedRows = computed(() => filtered.value.filter(row => selected.value.includes(row.id)))
const allPageSelected = computed(
() => pageRows.value.length > 0 && pageRows.value.every(row => selected.value.includes(row.id)),
)
watch(
[search, status, typeId, visibility, dateFrom, dateTo, order, () => props.owner, pageSize],
() => {
page.value = 1
selected.value = []
},
)
watch(filtered, rows => {
const valid = new Set(rows.map(row => row.id))
selected.value = selected.value.filter(id => valid.has(id))
page.value = Math.min(page.value, Math.max(1, Math.ceil(rows.length / pageSize.value)))
})
function reset() {
search.value = ''
status.value = ''
typeId.value = ''
visibility.value = ''
dateFrom.value = ''
dateTo.value = ''
order.value = 'newest'
emit('clearOwner')
}
function toggle(id: string) {
selected.value = selected.value.includes(id)
? selected.value.filter(value => value !== id)
: [...selected.value, id]
}
function togglePage() {
const ids = new Set(pageRows.value.map(row => row.id))
selected.value = allPageSelected.value
? selected.value.filter(id => !ids.has(id))
: [...new Set([...selected.value, ...ids])]
}
function exportRows() {
exportCsv(
props.review ? '待审核云图' : '云图清单',
['ID', '云类型', '上传者', '审核状态', '可见性', '上传时间', '拍摄时间', '说明'],
filtered.value.map(row => [
row.id,
row.cloud_type_name,
row.username,
statusMeta[row.status].label,
visibilityLabel(row),
row.created_at,
row.captured_at,
row.description,
]),
)
}
function action(label: string, handler: () => void, variant = 'neutral', disabled = false) {
return h(
NButton,
{
size: 'tiny',
type: 'default',
class: `oc-panel-button oc-panel-button--${variant}`,
disabled: props.busy || disabled,
onClick: handler,
},
{ default: () => label },
)
}
const columns = computed<DataTableColumns<CloudDetail>>(() => [
{ type: 'selection', disabled: () => props.busy },
{
title: '云图',
key: 'image',
width: 82,
render: row =>
h(
'button',
{
class: 'admin-thumbnail',
'aria-label': `查看${row.cloud_type_name}详情`,
onClick: () => emit('preview', row),
},
[
h('img', {
src: row.thumbnail_url || row.image_url,
alt: row.cloud_type_name || '云图',
loading: 'lazy',
}),
],
),
},
{
title: '云类型 / 说明',
key: 'cloud_type_name',
width: 200,
render: row =>
h('div', [
h('p', { class: 'font-medium text-slate-900' }, row.cloud_type_name || '未知云型'),
h(
'p',
{ class: 'mt-1 truncate text-xs text-slate-500', title: row.description || '' },
row.description || '无说明',
),
]),
},
{ title: '上传者', key: 'username', width: 125, ellipsis: { tooltip: true } },
{
title: '审核状态',
key: 'status',
width: 100,
render: row =>
h(
NTag,
{ size: 'small', type: statusMeta[row.status].type, bordered: false },
{ default: () => statusMeta[row.status].label },
),
},
{
title: '可见性',
key: 'visibility',
width: 100,
render: row => h('span', { class: 'text-xs text-slate-500' }, visibilityLabel(row)),
},
{
title: '上传时间',
key: 'created_at',
width: 155,
render: row => h('span', { class: 'text-xs tabular-nums' }, formatDateTime(row.created_at)),
},
{
title: '操作',
key: 'actions',
width: 230,
fixed: compact.value ? undefined : 'right',
render: row =>
h('div', { class: 'flex flex-wrap gap-2 py-1' }, [
action('详情', () => emit('preview', row)),
action(
'通过',
() => emit('review', [row.id], 'approved'),
'teal',
row.status === 'approved',
),
action(
'拒绝',
() => emit('review', [row.id], 'rejected'),
'amber',
row.status === 'rejected',
),
...(!props.review ? [action('删除', () => emit('delete', [row]), 'danger')] : []),
]),
},
])
</script>
<template>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>
{{ review ? '待审核队列' : '云图资源' }}
<span class="ml-2 text-sm font-normal text-slate-400"
>{{ filtered.length }} 张</span
>
</h2>
<p>
{{
review
? '核对上传内容后通过或拒绝,支持批量处理'
: '搜索、检查和管理所有状态的云图'
}}
</p>
</div>
<NButton
type="default"
size="small"
class="oc-panel-button oc-panel-button--neutral"
:disabled="!filtered.length || busy"
@click="exportRows"
><template #icon
><NIcon><Download /></NIcon></template
>导出清单</NButton
>
</div>
<div class="admin-filters">
<div class="admin-filter-search">
<label class="oc-field-label" :for="review ? 'review-search' : 'cloud-search'"
>搜索云图</label
><NInput
v-model:value="search"
clearable
placeholder="云类型、上传者、说明或 ID"
:input-props="{ id: review ? 'review-search' : 'cloud-search' }"
><template #prefix
><NIcon><Search /></NIcon></template
></NInput>
</div>
<div v-if="!review">
<label class="oc-field-label" for="cloud-status">审核状态</label
><select id="cloud-status" v-model="status" class="oc-field-control">
<option value="">全部状态</option>
<option v-for="(meta, key) in statusMeta" :key="key" :value="key">
{{ meta.label }}
</option>
</select>
</div>
<div>
<label class="oc-field-label" for="cloud-type-filter">云类型</label
><select id="cloud-type-filter" v-model="typeId" class="oc-field-control">
<option value="">全部云类型</option>
<option v-for="type in cloudTypes" :key="type.id" :value="String(type.id)">
{{ type.name }}
</option>
</select>
</div>
<div v-if="!review && (!compact || advancedFilters)">
<label class="oc-field-label" for="cloud-visibility">可见性</label
><select id="cloud-visibility" v-model="visibility" class="oc-field-control">
<option value="">全部可见性</option>
<option value="public">公开可见</option>
<option value="hidden">用户隐藏</option>
</select>
</div>
<div v-if="!compact || advancedFilters">
<label class="oc-field-label" for="cloud-date-from">上传开始日期</label
><input
id="cloud-date-from"
v-model="dateFrom"
type="date"
class="oc-field-control"
:max="dateTo || undefined"
/>
</div>
<div v-if="!compact || advancedFilters">
<label class="oc-field-label" for="cloud-date-to">上传结束日期</label
><input
id="cloud-date-to"
v-model="dateTo"
type="date"
class="oc-field-control"
:min="dateFrom || undefined"
/>
</div>
<div class="self-end flex flex-wrap gap-3">
<NButton
v-if="compact"
type="default"
class="oc-panel-button oc-panel-button--neutral"
:aria-expanded="advancedFilters"
@click="advancedFilters = !advancedFilters"
>{{ advancedFilters ? '收起筛选' : '更多筛选' }}</NButton
>
<NButton
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="reset"
>重置筛选</NButton
>
</div>
</div>
<div
v-if="owner"
class="mx-5 mb-4 flex items-center gap-3 border border-sky-200 bg-sky-50 p-3 text-sm text-sky-800"
>
正在查看指定用户的上传
<button type="button" class="oc-text-button" @click="emit('clearOwner')">
清除筛选
</button>
</div>
<div class="admin-table-toolbar">
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm text-slate-600"
><input
type="checkbox"
:checked="allPageSelected"
:disabled="!pageRows.length || busy"
@change="togglePage"
/>选择本页</label
><span class="text-xs text-slate-400">已选 {{ selectedRows.length }} 张</span
><button
v-if="selected.length"
type="button"
class="oc-text-button"
:disabled="busy"
@click="selected = []"
>
清空
</button>
</div>
<div class="flex items-center gap-3">
<select v-model="order" class="admin-sort" aria-label="云图排序">
<option value="newest">最新上传优先</option>
<option value="oldest">最早上传优先</option>
</select>
<div class="flex gap-2">
<button
type="button"
class="oc-icon-button oc-icon-button--small"
:class="view === 'table' ? 'text-teal-700' : ''"
aria-label="表格视图"
:aria-pressed="view === 'table'"
@click="view = 'table'"
>
<NIcon><List /></NIcon></button
><button
type="button"
class="oc-icon-button oc-icon-button--small"
:class="view === 'grid' ? 'text-teal-700' : ''"
aria-label="网格视图"
:aria-pressed="view === 'grid'"
@click="view = 'grid'"
>
<NIcon><LayoutGrid /></NIcon>
</button>
</div>
</div>
</div>
<div v-if="selectedRows.length" class="admin-batch-toolbar">
<span class="text-sm font-medium">已选择 {{ selectedRows.length }} 张云图</span
><NButton
size="small"
class="oc-panel-button oc-panel-button--teal"
type="default"
:disabled="busy"
@click="
emit(
'review',
selectedRows.map(row => row.id),
'approved',
)
"
>批量通过</NButton
><NButton
size="small"
class="oc-panel-button oc-panel-button--amber"
type="default"
:disabled="busy"
@click="
emit(
'review',
selectedRows.map(row => row.id),
'rejected',
)
"
>批量拒绝</NButton
><NButton
v-if="!review"
size="small"
class="oc-panel-button oc-panel-button--danger"
type="default"
:disabled="busy"
@click="emit('delete', selectedRows)"
>批量删除</NButton
>
</div>
<NDataTable
v-if="view === 'table'"
class="admin-data-table"
:columns="columns"
:data="pageRows"
:row-key="row => row.id"
:checked-row-keys="selected"
:scroll-x="992"
:bordered="false"
:pagination="false"
@update:checked-row-keys="keys => (selected = keys.map(String))"
><template #empty
><NEmpty
:description="
review && !clouds.some(row => row.status === 'pending')
? '审核队列已清空'
: '没有符合筛选条件的云图'
" /></template
></NDataTable>
<div v-else-if="pageRows.length" class="admin-cloud-grid">
<article
v-for="cloud in pageRows"
:key="cloud.id"
class="border border-slate-200 bg-white"
>
<button
type="button"
class="block w-full bg-sky-50"
:aria-label="`查看${cloud.cloud_type_name}详情`"
@click="emit('preview', cloud)"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
:alt="cloud.cloud_type_name || '云图'"
class="aspect-[4/3] w-full object-cover"
loading="lazy"
/>
</button>
<div class="space-y-3 p-4">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 items-center gap-2 font-medium"
><input
type="checkbox"
:checked="selected.includes(cloud.id)"
:disabled="busy"
@change="toggle(cloud.id)"
/><span class="truncate">{{ cloud.cloud_type_name }}</span></label
><NTag
size="small"
:bordered="false"
:type="statusMeta[cloud.status].type"
>{{ statusMeta[cloud.status].label }}</NTag
>
</div>
<p class="truncate text-xs text-slate-500">
{{ cloud.username }} · {{ visibilityLabel(cloud) }}
</p>
<p class="text-xs text-slate-400">{{ formatDateTime(cloud.created_at) }}</p>
<div class="flex flex-wrap gap-2">
<NButton
type="default"
size="small"
class="oc-panel-button oc-panel-button--teal"
:disabled="busy || cloud.status === 'approved'"
@click="emit('review', [cloud.id], 'approved')"
>通过</NButton
><NButton
type="default"
size="small"
class="oc-panel-button oc-panel-button--amber"
:disabled="busy || cloud.status === 'rejected'"
@click="emit('review', [cloud.id], 'rejected')"
>拒绝</NButton
><NButton
v-if="!review"
type="default"
size="small"
class="oc-panel-button oc-panel-button--danger"
:disabled="busy"
@click="emit('delete', [cloud])"
>删除</NButton
>
</div>
</div>
</article>
</div>
<NEmpty v-else class="py-12" description="没有符合筛选条件的云图" />
<div class="admin-pagination">
<p class="text-xs text-slate-500">
共 {{ filtered.length }} 张 · 第 {{ page }} /
{{ Math.max(1, Math.ceil(filtered.length / pageSize)) }} 页
</p>
<div class="flex items-center gap-3">
<select v-model.number="pageSize" class="admin-sort" aria-label="每页云图数量">
<option :value="20">20 条 / 页</option>
<option :value="50">50 条 / 页</option>
<option :value="100">100 条 / 页</option></select
><NPagination
v-model:page="page"
:page-size="pageSize"
:item-count="filtered.length"
simple
/>
</div>
</div>
</section>
</template>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import { NButton, NDataTable, NEmpty, NIcon, NInput, type DataTableColumns } from 'naive-ui'
import { Plus, Search } from '@vicons/tabler'
import type { CloudTypeResponse } from '@/types/api-contract'
import type { CloudDetail } from '@/types/view-models'
import { useAdminViewport } from '@/features/admin/composables/useAdminViewport'
const compact = useAdminViewport()
const props = defineProps<{
cloudTypes: CloudTypeResponse[]
clouds: CloudDetail[]
busy: boolean
}>()
const emit = defineEmits<{ edit: [type?: CloudTypeResponse]; delete: [type: CloudTypeResponse] }>()
const search = ref('')
const usage = ref('')
const page = ref(1)
function resetFilters() {
search.value = ''
usage.value = ''
}
const counts = computed(() => {
const result = new Map<number, number>()
for (const cloud of props.clouds) {
if (cloud.cloud_type_id !== null)
result.set(cloud.cloud_type_id, (result.get(cloud.cloud_type_id) || 0) + 1)
}
return result
})
const filtered = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.cloudTypes.filter(
type =>
(!query ||
[type.name, type.genus, type.description].some(value =>
value?.toLocaleLowerCase().includes(query),
)) &&
(!usage.value ||
(usage.value === 'used'
? (counts.value.get(type.id) || 0) > 0
: !counts.value.get(type.id))),
)
})
watch([search, usage], () => {
page.value = 1
})
watch(filtered, rows => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(rows.length / 20)))
})
const columns = computed<DataTableColumns<CloudTypeResponse>>(() => [
{ title: 'ID', key: 'id', width: 70 },
{
title: '云类型',
key: 'name',
width: 140,
render: type => h('span', { class: 'font-semibold text-slate-900' }, type.name),
},
{ title: '云属', key: 'genus', width: 130, render: type => type.genus || '未设置' },
{ title: '稀有度', key: 'rarity', width: 90, sorter: 'default' },
{ title: '辨识说明', key: 'description', width: 280, ellipsis: { tooltip: true } },
{
title: '关联云图',
key: 'usage',
width: 100,
render: type => `${counts.value.get(type.id) || 0} 张`,
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: compact.value ? undefined : 'right',
render: type =>
h('div', { class: 'flex gap-3 py-1' }, [
h(
NButton,
{
size: 'tiny',
type: 'default',
class: 'oc-panel-button oc-panel-button--neutral',
disabled: props.busy,
onClick: () => emit('edit', type),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'tiny',
type: 'default',
class: 'oc-panel-button oc-panel-button--danger',
disabled: props.busy || !!counts.value.get(type.id),
title: counts.value.get(type.id) ? '仍有云图引用,无法删除' : '删除云类型',
onClick: () => emit('delete', type),
},
{ default: () => '删除' },
),
]),
},
])
</script>
<template>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>
云类型目录
<span class="ml-2 text-sm font-normal text-slate-400"
>{{ cloudTypes.length }} 类</span
>
</h2>
<p>维护图鉴资料、云属与稀有度,已被云图引用的类型不可删除</p>
</div>
<NButton
type="default"
size="small"
class="oc-panel-button oc-panel-button--sky"
:disabled="busy"
@click="emit('edit')"
><template #icon
><NIcon><Plus /></NIcon></template
>新增云类型</NButton
>
</div>
<div class="admin-filters">
<div class="admin-filter-search">
<label for="type-search" class="oc-field-label">搜索云类型</label
><NInput
v-model:value="search"
clearable
placeholder="名称、云属或辨识说明"
:input-props="{ id: 'type-search' }"
><template #prefix
><NIcon><Search /></NIcon></template
></NInput>
</div>
<div>
<label for="type-usage" class="oc-field-label">使用情况</label
><select id="type-usage" v-model="usage" class="oc-field-control">
<option value="">全部类型</option>
<option value="used">已被引用</option>
<option value="unused">尚未使用</option>
</select>
</div>
<div class="self-end">
<NButton
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="resetFilters"
>重置筛选</NButton
>
</div>
</div>
<NDataTable
class="admin-data-table"
:columns="columns"
:data="filtered"
:row-key="type => type.id"
:bordered="false"
:scroll-x="960"
:pagination="{
page,
pageSize: 20,
simple: true,
onChange: (value: number) => (page = value),
}"
:paginate-single-page="false"
><template #empty><NEmpty description="没有符合筛选条件的云类型" /></template
></NDataTable>
<div class="admin-pagination">
<span class="text-xs text-slate-500">共 {{ filtered.length }} 个云类型</span>
</div>
</section>
</template>
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NIcon, NTag } from 'naive-ui'
import { ArrowRight, ClipboardCheck, Cloud, Photo, Users } from '@vicons/tabler'
import type { CloudDetail, AuthUser } from '@/types/view-models'
import type { CloudTypeResponse } from '@/types/api-contract'
import { formatDateTime, statusMeta, type AdminSection } from '@/features/admin/models'
const props = defineProps<{
clouds: CloudDetail[]
users: AuthUser[]
cloudTypes: CloudTypeResponse[]
}>()
const emit = defineEmits<{ navigate: [section: AdminSection]; preview: [cloud: CloudDetail] }>()
const pending = computed(() => props.clouds.filter(row => row.status === 'pending'))
const approved = computed(() => props.clouds.filter(row => row.status === 'approved').length)
const publicCount = computed(
() => props.clouds.filter(row => row.status === 'approved' && !row.is_hidden).length,
)
const recent = computed(() =>
[...props.clouds]
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
.slice(0, 5),
)
const trend = computed(() => {
const now = new Date()
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6 + index)
const next = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
return {
label: `${date.getMonth() + 1}/${date.getDate()}`,
count: props.clouds.filter(row => {
const timestamp = Date.parse(row.created_at)
return timestamp >= date.getTime() && timestamp < next.getTime()
}).length,
}
})
})
const maxUploads = computed(() => Math.max(1, ...trend.value.map(day => day.count)))
const stats = computed(() => [
{
label: '云图总量',
value: props.clouds.length,
detail: `${publicCount.value} 张公开可见`,
icon: Photo,
section: 'images' as const,
tone: 'sky',
},
{
label: '待审核',
value: pending.value.length,
detail: pending.value.length ? '需要管理员处理' : '审核队列已清空',
icon: ClipboardCheck,
section: 'review' as const,
tone: 'amber',
},
{
label: '注册用户',
value: props.users.length,
detail: `${props.users.filter(user => user.role === 'admin').length} 位管理员`,
icon: Users,
section: 'users' as const,
tone: 'teal',
},
{
label: '云类型',
value: props.cloudTypes.length,
detail: '维护云图分类与图鉴',
icon: Cloud,
section: 'cloud-types' as const,
tone: 'slate',
},
])
const distribution = computed(() =>
(['approved', 'pending', 'rejected'] as const).map(status => ({
status,
count: props.clouds.filter(row => row.status === status).length,
percent: props.clouds.length
? Math.round(
(props.clouds.filter(row => row.status === status).length / props.clouds.length) *
100,
)
: 0,
})),
)
const oldestPending = computed(
() => [...pending.value].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))[0],
)
</script>
<template>
<div class="space-y-6">
<div class="admin-stat-grid">
<button
v-for="stat in stats"
:key="stat.label"
class="admin-stat oc-panel-card"
type="button"
@click="emit('navigate', stat.section)"
>
<div class="flex items-center justify-between gap-3">
<span class="oc-stat-label">{{ stat.label }}</span>
<span class="admin-stat-icon" :class="`admin-stat-icon--${stat.tone}`"
><NIcon size="20"><component :is="stat.icon" /></NIcon
></span>
</div>
<p class="mt-3 text-3xl font-semibold tabular-nums text-slate-900">
{{ stat.value.toLocaleString() }}
</p>
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-slate-500">
<span>{{ stat.detail }}</span
><NIcon><ArrowRight /></NIcon>
</div>
</button>
</div>
<section
class="admin-priority oc-panel-card"
:class="pending.length ? 'admin-priority--pending' : ''"
>
<span class="admin-stat-icon admin-stat-icon--amber"
><NIcon size="24"><ClipboardCheck /></NIcon
></span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-slate-900">
{{
pending.length
? `有 ${pending.length} 张云图等待审核`
: '待办已处理,天空档案持续更新'
}}
</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">
{{
oldestPending
? `最早提交于 ${formatDateTime(oldestPending.created_at)},优先检查图片、云型与拍摄信息。`
: '查看近期上传,或维护用户与云类型资料。'
}}
</p>
</div>
<NButton
type="default"
class="oc-panel-button oc-panel-button--amber"
@click="emit('navigate', pending.length ? 'review' : 'images')"
>{{ pending.length ? '进入审核' : '查看云图'
}}<template #icon
><NIcon><ArrowRight /></NIcon></template
></NButton>
</section>
<div class="admin-overview-grid">
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>上传趋势</h2>
<p>最近 7 天 · 按上传时间统计</p>
</div>
<NTag size="small" :bordered="false" type="info"
>今日 {{ trend[6]?.count ?? 0 }} 张</NTag
>
</div>
<div
class="admin-chart"
role="img"
:aria-label="trend.map(day => `${day.label}:${day.count} 张`).join(';')"
>
<div v-for="(day, index) in trend" :key="day.label" class="admin-chart-column">
<span class="text-xs tabular-nums text-slate-500">{{ day.count }}</span>
<div class="admin-chart-track">
<div
class="admin-chart-bar"
:class="index === 6 ? 'admin-chart-bar--today' : ''"
:style="{
height: `${Math.max(day.count ? 4 : 0, (day.count / maxUploads) * 100)}%`,
}"
></div>
</div>
<span class="text-xs text-slate-500">{{ day.label }}</span>
</div>
</div>
</section>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>内容状态</h2>
<p>当前全部云图的审核分布</p>
</div>
</div>
<div class="space-y-5 px-5 py-6">
<div v-for="item in distribution" :key="item.status">
<div class="mb-2 flex justify-between text-sm">
<span class="text-slate-600">{{ statusMeta[item.status].label }}</span
><span class="tabular-nums text-slate-900"
>{{ item.count }}
<span class="ml-2 text-xs text-slate-400"
>{{ item.percent }}%</span
></span
>
</div>
<div class="h-2 bg-slate-100">
<div
class="h-full"
:class="{
'bg-emerald-500': item.status === 'approved',
'bg-amber-400': item.status === 'pending',
'bg-rose-400': item.status === 'rejected',
}"
:style="{ width: `${item.percent}%` }"
></div>
</div>
</div>
<p class="border-t border-slate-100 pt-4 text-xs leading-5 text-slate-500">
已通过 {{ approved }} 张,其中 {{ publicCount }} 张公开展示。
</p>
</div>
</section>
</div>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>最近上传</h2>
<p>最近提交的 5 张云图</p>
</div>
<button class="oc-text-button" type="button" @click="emit('navigate', 'images')">
全部云图 →
</button>
</div>
<div v-if="!recent.length" class="py-12 text-center text-sm text-slate-500">
暂无上传记录
</div>
<button
v-for="cloud in recent"
:key="cloud.id"
type="button"
class="admin-recent-row"
@click="emit('preview', cloud)"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
:alt="cloud.cloud_type_name || '云图'"
class="h-12 w-16 shrink-0 border border-slate-200 bg-sky-50 object-cover"
loading="lazy"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-slate-900">
{{ cloud.cloud_type_name || '未知云型' }}
</p>
<p class="mt-1 truncate text-xs text-slate-500">
{{ cloud.username }} · {{ formatDateTime(cloud.created_at) }}
</p>
</div>
<NTag size="small" :type="statusMeta[cloud.status].type" :bordered="false">{{
statusMeta[cloud.status].label
}}</NTag>
<NIcon class="hidden text-slate-400 sm:block"><ArrowRight /></NIcon>
</button>
</section>
</div>
</template>
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import {
NButton,
NDataTable,
NEmpty,
NIcon,
NInput,
NModal,
NPagination,
NTag,
type DataTableColumns,
} from 'naive-ui'
import { Download, Plus, Search } from '@vicons/tabler'
import type { AuthUser } from '@/types/view-models'
import { exportCsv, formatDateTime } from '@/features/admin/models'
import { useAdminViewport } from '@/features/admin/composables/useAdminViewport'
const compact = useAdminViewport()
const props = defineProps<{ users: AuthUser[]; currentUserId?: string; busy: boolean }>()
const emit = defineEmits<{
create: []
role: [user: AuthUser]
password: [user: AuthUser]
clouds: [user: AuthUser]
}>()
const search = ref('')
const role = ref('')
const state = ref('')
const page = ref(1)
const pageSize = ref(20)
const detailId = ref<string | null>(null)
const detail = computed(() => props.users.find(user => user.id === detailId.value) ?? null)
const filtered = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.users.filter(
user =>
(!role.value || user.role === role.value) &&
(!state.value || user.is_disabled === (state.value === 'disabled')) &&
(!query ||
[user.username, user.email, user.id].some(value =>
value.toLocaleLowerCase().includes(query),
)),
)
})
watch([search, role, state, pageSize], () => {
page.value = 1
})
watch(filtered, rows => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(rows.length / pageSize.value)))
})
const pageRows = computed(() =>
filtered.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value),
)
function resetFilters() {
search.value = ''
role.value = ''
state.value = ''
}
function viewUploads() {
if (detail.value) emit('clouds', detail.value)
detailId.value = null
}
function exportUsers() {
exportCsv(
'用户清单',
['ID', '用户名', '邮箱', '角色', '账户状态', '上传数', '获赞数', '注册时间'],
filtered.value.map(user => [
user.id,
user.username,
user.email,
user.role === 'admin' ? '管理员' : '普通用户',
user.is_disabled ? '已停用' : '正常',
user.cloud_count,
user.received_like_count,
user.created_at,
]),
)
}
function button(label: string, handler: () => void, variant = 'neutral', disabled = false) {
return h(
NButton,
{
size: 'tiny',
type: 'default',
class: `oc-panel-button oc-panel-button--${variant}`,
disabled: props.busy || disabled,
onClick: handler,
},
{ default: () => label },
)
}
const columns = computed<DataTableColumns<AuthUser>>(() => [
{
title: '用户',
key: 'username',
width: 170,
render: user =>
h('div', { class: 'flex items-center gap-3' }, [
h('span', { class: 'admin-user-avatar' }, user.username.slice(0, 1).toUpperCase()),
h('div', { class: 'min-w-0' }, [
h(
'p',
{ class: 'truncate font-medium text-slate-900', title: user.username },
user.username,
),
user.id === props.currentUserId
? h('span', { class: 'text-xs text-teal-700' }, '当前账户')
: null,
]),
]),
},
{ title: '邮箱', key: 'email', width: 210, ellipsis: { tooltip: true } },
{
title: '角色',
key: 'role',
width: 100,
render: user =>
h(
NTag,
{
size: 'small',
type: user.role === 'admin' ? 'info' : 'default',
bordered: false,
},
{ default: () => (user.role === 'admin' ? '管理员' : '普通用户') },
),
},
{
title: '状态',
key: 'is_disabled',
width: 90,
render: user =>
h(
NTag,
{ size: 'small', type: user.is_disabled ? 'error' : 'success', bordered: false },
{ default: () => (user.is_disabled ? '已停用' : '正常') },
),
},
{
title: '上传 / 获赞',
key: 'cloud_count',
width: 115,
render: user => `${user.cloud_count} / ${user.received_like_count}`,
},
{
title: '注册时间',
key: 'created_at',
width: 155,
render: user =>
h('span', { class: 'text-xs tabular-nums' }, formatDateTime(user.created_at)),
},
{
title: '操作',
key: 'actions',
width: 250,
fixed: compact.value ? undefined : 'right',
render: user =>
h('div', { class: 'flex flex-wrap gap-2 py-1' }, [
button('详情', () => (detailId.value = user.id)),
button('重置密码', () => emit('password', user), 'amber'),
button(
user.role === 'admin' ? '撤销管理' : '设为管理',
() => emit('role', user),
'neutral',
user.id === props.currentUserId,
),
]),
},
])
</script>
<template>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>
用户账户
<span class="ml-2 text-sm font-normal text-slate-400"
>{{ users.length }} 位</span
>
</h2>
<p>查找用户、管理权限与登录凭据</p>
</div>
<div class="flex flex-wrap gap-3">
<NButton
type="default"
size="small"
class="oc-panel-button oc-panel-button--neutral"
:disabled="!filtered.length || busy"
@click="exportUsers"
><template #icon
><NIcon><Download /></NIcon></template
>导出清单</NButton
><NButton
type="default"
size="small"
class="oc-panel-button oc-panel-button--sky"
:disabled="busy"
@click="emit('create')"
><template #icon
><NIcon><Plus /></NIcon></template
>创建用户</NButton
>
</div>
</div>
<div class="admin-filters">
<div class="admin-filter-search">
<label for="user-search" class="oc-field-label">搜索用户</label
><NInput
v-model:value="search"
clearable
placeholder="用户名、邮箱或 ID"
:input-props="{ id: 'user-search' }"
><template #prefix
><NIcon><Search /></NIcon></template
></NInput>
</div>
<div>
<label for="user-role" class="oc-field-label">用户角色</label
><select id="user-role" v-model="role" class="oc-field-control">
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
</div>
<div>
<label for="user-state" class="oc-field-label">账户状态</label
><select id="user-state" v-model="state" class="oc-field-control">
<option value="">全部状态</option>
<option value="active">正常</option>
<option value="disabled">已停用</option>
</select>
</div>
<div class="self-end">
<NButton
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="resetFilters"
>重置筛选</NButton
>
</div>
</div>
<NDataTable
class="admin-data-table"
:columns="columns"
:data="pageRows"
:row-key="user => user.id"
:bordered="false"
:scroll-x="1190"
><template #empty><NEmpty description="没有符合筛选条件的用户" /></template
></NDataTable>
<div class="admin-pagination">
<span class="text-xs text-slate-500">共 {{ filtered.length }} 位用户</span>
<div class="flex items-center gap-3">
<select v-model.number="pageSize" class="admin-sort" aria-label="每页用户数量">
<option :value="20">20 条 / 页</option>
<option :value="50">50 条 / 页</option></select
><NPagination
v-model:page="page"
:page-size="pageSize"
:item-count="filtered.length"
simple
/>
</div>
</div>
</section>
<NModal
:show="!!detail"
preset="card"
title="用户详情"
class="admin-form-modal"
@update:show="
show => {
if (!show) detailId = null
}
"
>
<template v-if="detail"
><div class="flex items-center gap-3">
<span class="admin-user-avatar">{{
detail.username.slice(0, 1).toUpperCase()
}}</span>
<div class="min-w-0">
<p class="truncate text-lg font-semibold">{{ detail.username }}</p>
<p class="break-all text-sm text-slate-500">{{ detail.email }}</p>
</div>
</div>
<dl class="my-6 grid grid-cols-2 gap-4">
<div class="oc-inset-panel">
<dt class="oc-section-label">上传云图</dt>
<dd class="mt-2 text-xl font-semibold">{{ detail.cloud_count }}</dd>
</div>
<div class="oc-inset-panel">
<dt class="oc-section-label">获得点赞</dt>
<dd class="mt-2 text-xl font-semibold">{{ detail.received_like_count }}</dd>
</div>
<div>
<dt class="oc-field-label">用户角色</dt>
<dd>{{ detail.role === 'admin' ? '管理员' : '普通用户' }}</dd>
</div>
<div>
<dt class="oc-field-label">账户状态</dt>
<dd>{{ detail.is_disabled ? '已停用' : '正常' }}</dd>
</div>
<div class="col-span-2">
<dt class="oc-field-label">用户 ID</dt>
<dd class="break-all text-xs text-slate-500">{{ detail.id }}</dd>
</div>
<div class="col-span-2">
<dt class="oc-field-label">注册时间</dt>
<dd>{{ formatDateTime(detail.created_at) }}</dd>
</div>
</dl>
<NButton
type="default"
class="oc-panel-button oc-panel-button--sky"
@click="viewUploads"
>查看该用户上传</NButton
></template
>
</NModal>
</template>
@@ -0,0 +1,298 @@
import { computed, ref } from 'vue'
import { useDialog, useMessage, type ButtonProps } from 'naive-ui'
import { useAuthStore } from '@/features/auth/stores/auth'
import { useCloudsStore } from '@/features/clouds/stores/clouds'
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
import { listCloudTypeRecords } from '@/features/clouds/api'
import { toCloudType } from '@/lib/api-adapters'
import {
listUsers,
listAdminClouds,
reviewClouds,
deleteAdminCloud,
setUserRole,
setUserPassword,
createUser,
createCloudType,
updateCloudType,
deleteCloudTypeRecord,
} from '@/features/admin/api'
import type { CloudDetail, AuthUser } from '@/types/view-models'
import type { CloudTypeResponse, CreateCloudTypeBody, CreateUserBody } from '@/types/api-contract'
import type { ReviewStatus } from '@/features/admin/models'
export function useAdminData() {
const auth = useAuthStore()
const cloudsStore = useCloudsStore()
const encyclopediaStore = useEncyclopediaStore()
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const busy = ref(false)
const error = ref('')
const loaded = ref(false)
const refreshedAt = ref<string | null>(null)
const users = ref<AuthUser[]>([])
const clouds = ref<CloudDetail[]>([])
const cloudTypes = ref<CloudTypeResponse[]>([])
const pendingCount = computed(
() => clouds.value.filter(item => item.status === 'pending').length,
)
const errorText = (reason: unknown) =>
reason instanceof Error ? reason.message : '操作失败,请稍后重试'
async function fetchTypes() {
cloudTypes.value = await listCloudTypeRecords()
const normalized = cloudTypes.value.map(toCloudType)
cloudsStore.setCloudTypes(normalized)
encyclopediaStore.setCloudTypes(normalized)
}
async function refresh() {
if (loading.value || busy.value) return
loading.value = true
error.value = ''
const results = await Promise.allSettled([
listUsers().then(rows => {
users.value = rows
}),
listAdminClouds().then(rows => {
clouds.value = rows
}),
fetchTypes(),
])
const failed = results.filter(result => result.status === 'rejected')
if (failed.length) {
error.value = failed.map(result => errorText(result.reason)).join(';')
} else {
loaded.value = true
refreshedAt.value = new Date().toISOString()
}
loading.value = false
}
async function mutate(action: () => Promise<void>) {
if (busy.value || loading.value) return false
busy.value = true
error.value = ''
try {
await action()
return true
} catch (reason) {
error.value = errorText(reason)
message.error(error.value)
return false
} finally {
busy.value = false
}
}
function confirm(
title: string,
content: string,
action: () => Promise<boolean>,
danger = false,
) {
if (busy.value || loading.value) return
const positiveButtonProps: ButtonProps & { class: string } = {
type: 'default',
class: `oc-panel-button oc-panel-button--${danger ? 'danger' : 'teal'}`,
}
const negativeButtonProps: ButtonProps & { class: string } = {
type: 'default',
class: 'oc-panel-button oc-panel-button--neutral',
}
const instance = dialog.warning({
title,
content,
positiveText: '确认',
negativeText: '取消',
maskClosable: false,
closeOnEsc: false,
positiveButtonProps,
negativeButtonProps,
zIndex: 2000,
onPositiveClick: async () => {
instance.loading = true
try {
return await action()
} finally {
instance.loading = false
}
},
onNegativeClick: () => !busy.value,
onClose: () => !busy.value,
})
}
function review(ids: string[], status: Exclude<ReviewStatus, 'pending'>) {
const targets = [...new Set(ids)].filter(id =>
clouds.value.some(item => item.id === id && item.status !== status),
)
if (!targets.length) return
confirm(
status === 'approved' ? '通过内容审核' : '拒绝内容审核',
`确认将 ${targets.length} 张云图设为${status === 'approved' ? '已通过' : '已拒绝'}?${status === 'approved' ? '通过后,未隐藏的云图将公开展示。' : '拒绝后,云图将不再公开展示。'}`,
() =>
mutate(async () => {
// Apply each completed batch locally so partial failures remain visible.
await reviewClouds(targets, status, batch => {
const completed = new Set(batch)
clouds.value = clouds.value.map(item =>
completed.has(item.id) ? { ...item, status } : item,
)
})
message.success(
`已${status === 'approved' ? '通过' : '拒绝'} ${targets.length} 张云图`,
)
}),
status === 'rejected',
)
}
function removeClouds(items: CloudDetail[]) {
if (!items.length) return
confirm(
'删除云图',
`确认永久删除 ${items.length} 张云图及其图片文件?此操作无法撤销。`,
() =>
mutate(async () => {
let completed = 0
for (const item of items) {
try {
await deleteAdminCloud(item.id)
clouds.value = clouds.value.filter(row => row.id !== item.id)
users.value = users.value.map(user =>
user.id === item.user_id
? {
...user,
cloud_count: Math.max(0, user.cloud_count - 1),
received_like_count: Math.max(
0,
user.received_like_count - item.received_like_count,
),
}
: user,
)
completed++
} catch (reason) {
throw new Error(
`已删除 ${completed} 张,剩余 ${items.length - completed} 张未删除:${errorText(reason)}`,
{ cause: reason },
)
}
}
message.success(`已删除 ${completed} 张云图`)
}),
true,
)
}
function changeRole(user: AuthUser) {
if (user.id === auth.user?.id) return
const role = user.role === 'admin' ? 'user' : 'admin'
confirm(
'调整用户角色',
`确认将「${user.username}」设为${role === 'admin' ? '管理员' : '普通用户'}?该用户的现有登录状态将失效。`,
() =>
mutate(async () => {
await setUserRole(user.id, role)
users.value = users.value.map(row =>
row.id === user.id ? { ...row, role } : row,
)
message.success('用户角色已更新')
}),
)
}
function resetPassword(id: string, password: string) {
return mutate(async () => {
await setUserPassword(id, password)
message.success('密码已重置,该用户需要重新登录')
if (id === auth.user?.id) {
auth.user = null
auth.profile = null
}
})
}
function addUser(body: CreateUserBody) {
return mutate(async () => {
const result = await createUser(body)
if (result.email_sent) message.success('用户已创建,验证邮件已发送')
else message.warning('用户已创建,验证邮件发送失败,可在登录页重发')
// Creation succeeded: a refresh failure must not invite duplicate submission.
try {
users.value = await listUsers()
} catch {
error.value = '用户已创建,列表刷新失败,请点击刷新数据'
}
})
}
function saveType(id: number | null, body: CreateCloudTypeBody) {
return mutate(async () => {
if (id === null) await createCloudType(body)
else await updateCloudType(id, body)
message.success(id === null ? '云类型已创建' : '云类型已更新')
try {
await fetchTypes()
clouds.value = clouds.value.map(item => {
const type = cloudTypes.value.find(type => type.id === item.cloud_type_id)
return type
? {
...item,
cloud_type_name: type.name,
cloud_type_rarity: type.rarity,
cloud_type: { id: type.id, name: type.name, rarity: type.rarity },
}
: item
})
} catch {
error.value = '云类型已保存,列表刷新失败,请点击刷新数据'
}
})
}
function removeType(type: CloudTypeResponse) {
const count = clouds.value.filter(item => item.cloud_type_id === type.id).length
if (count) {
message.warning(`该类型仍被 ${count} 张云图引用,无法删除`)
return
}
confirm(
'删除云类型',
`确认删除「${type.name}」?此操作无法撤销。`,
() =>
mutate(async () => {
await deleteCloudTypeRecord(type.id)
cloudTypes.value = cloudTypes.value.filter(row => row.id !== type.id)
const normalized = cloudTypes.value.map(toCloudType)
cloudsStore.setCloudTypes(normalized)
encyclopediaStore.setCloudTypes(normalized)
message.success('云类型已删除')
}),
true,
)
}
return {
loading,
busy,
loaded,
error,
refreshedAt,
users,
clouds,
cloudTypes,
pendingCount,
refresh,
review,
removeClouds,
changeRole,
resetPassword,
addUser,
saveType,
removeType,
}
}
@@ -0,0 +1,12 @@
import { onUnmounted, ref } from 'vue'
export function useAdminViewport() {
const query = window.matchMedia('(max-width: 639px)')
const compact = ref(query.matches)
const update = (event: MediaQueryListEvent) => {
compact.value = event.matches
}
query.addEventListener('change', update)
onUnmounted(() => query.removeEventListener('change', update))
return compact
}
+50
View File
@@ -0,0 +1,50 @@
import type { CloudDetail } from '@/types/view-models'
export type AdminSection = 'dashboard' | 'review' | 'images' | 'users' | 'cloud-types'
export type ReviewStatus = CloudDetail['status']
export const statusMeta = {
pending: { label: '待审核', type: 'warning' },
approved: { label: '已通过', type: 'success' },
rejected: { label: '已拒绝', type: 'error' },
} as const
export function formatDateTime(value: string | null) {
if (!value) return '未记录'
const date = new Date(value)
return Number.isNaN(date.getTime())
? '未记录'
: date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function visibilityLabel(cloud: Pick<CloudDetail, 'status' | 'is_hidden'>) {
if (cloud.is_hidden) return '用户隐藏'
return cloud.status === 'approved' ? '公开可见' : '尚未公开'
}
export function serializeCsv(headers: string[], rows: unknown[][]) {
const escape = (value: unknown) => {
let text = String(value ?? '')
// Spreadsheet programs interpret these prefixes as formulas, even inside quoted cells.
if (/^[\s]*[=+@-]/.test(text) || /^[\t\r\n]/.test(text)) text = `'${text}`
return `"${text.replaceAll('"', '""')}"`
}
return '\uFEFF' + [headers, ...rows].map(row => row.map(escape).join(',')).join('\r\n')
}
export function exportCsv(name: string, headers: string[], rows: unknown[][]) {
const url = URL.createObjectURL(
new Blob([serializeCsv(headers, rows)], { type: 'text/csv;charset=utf-8;' }),
)
const link = document.createElement('a')
link.href = url
link.download = `${name}-${new Date().toISOString().slice(0, 10)}.csv`
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
File diff suppressed because it is too large Load Diff