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,
|
CloudTypeCreateResponse,
|
||||||
ReviewCloudsBody,
|
ReviewCloudsBody,
|
||||||
SetUserRoleBody,
|
SetUserRoleBody,
|
||||||
|
CreateUserBody,
|
||||||
} from '@/types/api-contract'
|
} from '@/types/api-contract'
|
||||||
|
|
||||||
export async function listUsers() {
|
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) {
|
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||||
const body: ReviewCloudsBody = {
|
const body: ReviewCloudsBody = {
|
||||||
cloud_ids: ids.slice(offset, offset + 100),
|
cloud_ids: ids.slice(offset, offset + 100),
|
||||||
review_status: reviewStatus,
|
review_status: reviewStatus,
|
||||||
}
|
}
|
||||||
await apiRequest<MessageResponse>('/admin/clouds/review', { method: 'POST', body })
|
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) {
|
export function deleteAdminCloud(id: string) {
|
||||||
return apiRequest<MessageResponse>(`/admin/clouds/${encodeURIComponent(id)}`, {
|
return apiRequest<MessageResponse>(`/admin/clouds/${encodeURIComponent(id)}`, {
|
||||||
method: 'DELETE',
|
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
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { afterEach, mock, test } from 'node:test'
|
||||||
|
import { createUser, reviewClouds } from '../src/features/admin/api.ts'
|
||||||
|
import { serializeCsv, visibilityLabel } from '../src/features/admin/models.ts'
|
||||||
|
|
||||||
|
afterEach(() => mock.restoreAll())
|
||||||
|
|
||||||
|
test('admin review preserves completed batches when a later batch fails', async () => {
|
||||||
|
const completed = []
|
||||||
|
let call = 0
|
||||||
|
mock.method(globalThis, 'fetch', async (url, options) => {
|
||||||
|
assert.equal(new URL(url).pathname, '/admin/clouds/review')
|
||||||
|
assert.equal(options.credentials, 'include')
|
||||||
|
const body = JSON.parse(options.body)
|
||||||
|
assert.equal(body.review_status, 'approved')
|
||||||
|
assert.ok(body.cloud_ids.length <= 100)
|
||||||
|
return ++call === 1
|
||||||
|
? Response.json({ message: '成功' })
|
||||||
|
: Response.json({ message: '服务暂不可用' }, { status: 503 })
|
||||||
|
})
|
||||||
|
const ids = Array.from({ length: 101 }, (_, index) => `cloud-${index}`)
|
||||||
|
await assert.rejects(
|
||||||
|
reviewClouds(ids, 'approved', batch => completed.push(...batch)),
|
||||||
|
/服务暂不可用/,
|
||||||
|
)
|
||||||
|
assert.deepEqual(completed, ids.slice(0, 100))
|
||||||
|
assert.equal(call, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admin account creation returns email delivery status without retrying creation', async () => {
|
||||||
|
const body = {
|
||||||
|
username: '观云者',
|
||||||
|
email: 'observer@example.test',
|
||||||
|
password: 'test-password',
|
||||||
|
role: 'user',
|
||||||
|
}
|
||||||
|
const fetch = mock.method(globalThis, 'fetch', async (url, options) => {
|
||||||
|
assert.equal(new URL(url).pathname, '/admin/users')
|
||||||
|
assert.equal(options.method, 'POST')
|
||||||
|
assert.equal(options.credentials, 'include')
|
||||||
|
assert.deepEqual(JSON.parse(options.body), body)
|
||||||
|
return Response.json(
|
||||||
|
{ message: '已创建,邮件发送失败', user_id: 'new-user', email_sent: false },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
assert.equal((await createUser(body)).email_sent, false)
|
||||||
|
assert.equal(fetch.mock.callCount(), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSV exports escape untrusted text, formulas, quotes and multiline descriptions', () => {
|
||||||
|
const csv = serializeCsv(
|
||||||
|
['用户名', '说明'],
|
||||||
|
[
|
||||||
|
['=HYPERLINK("test")', '第一行\n第二行'],
|
||||||
|
[' @SUM(1)', '云,"雨"'],
|
||||||
|
['-1+2', '\t=1'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert.ok(csv.startsWith('\uFEFF'))
|
||||||
|
assert.ok(csv.includes('"\'=HYPERLINK(""test"")"'))
|
||||||
|
assert.ok(csv.includes('"\' @SUM(1)"'))
|
||||||
|
assert.ok(csv.includes('"\'-1+2"'))
|
||||||
|
assert.ok(csv.includes('"\'\t=1"'))
|
||||||
|
assert.ok(csv.includes('"云,""雨"""'))
|
||||||
|
assert.ok(csv.includes('"第一行\n第二行"'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending and rejected images are never labeled publicly visible', () => {
|
||||||
|
for (const status of ['pending', 'rejected']) {
|
||||||
|
assert.equal(visibilityLabel({ status, is_hidden: false }), '尚未公开')
|
||||||
|
}
|
||||||
|
assert.equal(visibilityLabel({ status: 'approved', is_hidden: false }), '公开可见')
|
||||||
|
assert.equal(visibilityLabel({ status: 'approved', is_hidden: true }), '用户隐藏')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user