feat(admin): 重构管理控制台布局并完善管理功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+733
-2212
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user