feat: 添加用户统计功能,更新用户状态接口,优化统计组件
This commit is contained in:
4
frontend/superadmin/dist/index.html
vendored
4
frontend/superadmin/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sakai Vue</title>
|
<title>Sakai Vue</title>
|
||||||
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
|
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="./assets/index-VWI_AnCM.js"></script>
|
<script type="module" crossorigin src="./assets/index-CurkpZlu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-B0E0aOXs.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BK0XSEUV.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
29
frontend/superadmin/src/components/StatisticsStrip.vue
Normal file
29
frontend/superadmin/src/components/StatisticsStrip.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
containerClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'card'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="props.containerClass">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-6">
|
||||||
|
<div v-for="(item, idx) in props.items" :key="item.key ?? idx" class="flex items-center gap-4 flex-1 min-w-[220px]">
|
||||||
|
<div class="w-12 h-12 rounded-full flex items-center justify-center bg-surface-100 dark:bg-surface-800">
|
||||||
|
<i class="pi text-primary text-xl" :class="item.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-muted-color">{{ item.label }}</span>
|
||||||
|
<span class="text-surface-900 dark:text-surface-0 text-xl font-semibold" :class="item.valueClass">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="idx !== props.items.length - 1" class="hidden xl:block w-px self-stretch bg-surface-200 dark:bg-surface-700 ml-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -23,13 +23,37 @@ export const UserService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async getUserStatuses() {
|
async getUserStatuses() {
|
||||||
const data = await requestJson('/super/v1/users/statuses');
|
try {
|
||||||
return Array.isArray(data) ? data : [];
|
const data = await requestJson('/super/v1/user/statuses');
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 404) {
|
||||||
|
const data = await requestJson('/super/v1/users/statuses');
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async updateUserStatus({ userID, status }) {
|
async updateUserStatus({ userID, status }) {
|
||||||
return requestJson(`/super/v1/users/${userID}/status`, {
|
try {
|
||||||
method: 'PATCH',
|
return await requestJson(`/super/v1/user/${userID}/status`, { method: 'PATCH', body: { status } });
|
||||||
body: { status }
|
} catch (error) {
|
||||||
});
|
if (error?.status === 404) {
|
||||||
|
return requestJson(`/super/v1/users/${userID}/status`, { method: 'PATCH', body: { status } });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getUserStatistics() {
|
||||||
|
try {
|
||||||
|
const data = await requestJson('/super/v1/users/statistics');
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 404) {
|
||||||
|
const data = await requestJson('/super/v1/users/statistic');
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import StatisticsStrip from '@/components/StatisticsStrip.vue';
|
||||||
import { UserService } from '@/service/UserService';
|
import { UserService } from '@/service/UserService';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -46,6 +47,57 @@ const statusOptions = ref([]);
|
|||||||
const statusUser = ref(null);
|
const statusUser = ref(null);
|
||||||
const statusValue = ref(null);
|
const statusValue = ref(null);
|
||||||
|
|
||||||
|
const statistics = ref([]);
|
||||||
|
const statisticsLoading = ref(false);
|
||||||
|
|
||||||
|
const statisticsItems = computed(() => {
|
||||||
|
const total = (statistics.value || []).reduce((sum, row) => sum + (Number(row?.count) || 0), 0);
|
||||||
|
const statusIcon = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'verified':
|
||||||
|
return 'pi-check-circle';
|
||||||
|
case 'pending_verify':
|
||||||
|
return 'pi-clock';
|
||||||
|
case 'banned':
|
||||||
|
return 'pi-ban';
|
||||||
|
default:
|
||||||
|
return 'pi-tag';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const valueClass = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'banned':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'pending_verify':
|
||||||
|
return 'text-orange-500';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'total', label: '用户总数:', value: statisticsLoading.value ? '-' : total, icon: 'pi-users' },
|
||||||
|
...(statistics.value || []).map((row) => ({
|
||||||
|
key: row?.status ?? row?.status_description,
|
||||||
|
label: `${row?.status_description || row?.status || '-'}:`,
|
||||||
|
value: statisticsLoading.value ? '-' : (row?.count ?? 0),
|
||||||
|
icon: statusIcon(row?.status),
|
||||||
|
valueClass: valueClass(row?.status)
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStatistics() {
|
||||||
|
statisticsLoading.value = true;
|
||||||
|
try {
|
||||||
|
statistics.value = await UserService.getUserStatistics();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载用户统计信息', life: 4000 });
|
||||||
|
} finally {
|
||||||
|
statisticsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureStatusOptionsLoaded() {
|
async function ensureStatusOptionsLoaded() {
|
||||||
if (statusOptions.value.length > 0) return;
|
if (statusOptions.value.length > 0) return;
|
||||||
const list = await UserService.getUserStatuses();
|
const list = await UserService.getUserStatuses();
|
||||||
@@ -82,6 +134,7 @@ async function confirmUpdateStatus() {
|
|||||||
toast.add({ severity: 'success', summary: '更新成功', detail: `用户ID: ${userID}`, life: 3000 });
|
toast.add({ severity: 'success', summary: '更新成功', detail: `用户ID: ${userID}`, life: 3000 });
|
||||||
statusDialogVisible.value = false;
|
statusDialogVisible.value = false;
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
|
await loadStatistics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新用户状态', life: 4000 });
|
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新用户状态', life: 4000 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,11 +194,13 @@ function onSort(event) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
loadStatistics();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<StatisticsStrip :items="statisticsItems" containerClass="card mb-4" />
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -163,19 +218,32 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable :value="users" dataKey="id" :loading="loading" lazy :paginator="true" :rows="rows"
|
<DataTable
|
||||||
:totalRecords="totalRecords" :first="(page - 1) * rows" :rowsPerPageOptions="[10, 20, 50, 100]"
|
:value="users"
|
||||||
sortMode="single" :sortField="sortField" :sortOrder="sortOrder" @page="onPage" @sort="onSort"
|
dataKey="id"
|
||||||
|
:loading="loading"
|
||||||
|
lazy
|
||||||
|
:paginator="true"
|
||||||
|
:rows="rows"
|
||||||
|
:totalRecords="totalRecords"
|
||||||
|
:first="(page - 1) * rows"
|
||||||
|
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||||
|
sortMode="single"
|
||||||
|
:sortField="sortField"
|
||||||
|
:sortOrder="sortOrder"
|
||||||
|
@page="onPage"
|
||||||
|
@sort="onSort"
|
||||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||||
scrollable scrollHeight="flex" responsiveLayout="scroll">
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
>
|
||||||
<Column field="id" header="ID" sortable style="min-width: 6rem" />
|
<Column field="id" header="ID" sortable style="min-width: 6rem" />
|
||||||
<Column field="username" header="用户名" sortable style="min-width: 14rem" />
|
<Column field="username" header="用户名" sortable style="min-width: 14rem" />
|
||||||
<Column field="status" header="状态" sortable style="min-width: 10rem">
|
<Column field="status" header="状态" sortable style="min-width: 10rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.status_description || data.status || '-'"
|
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openStatusDialog(data)" />
|
||||||
:severity="getStatusSeverity(data.status)" class="cursor-pointer"
|
|
||||||
@click="openStatusDialog(data)" />
|
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="roles" header="角色" style="min-width: 16rem">
|
<Column field="roles" header="角色" style="min-width: 16rem">
|
||||||
@@ -214,15 +282,12 @@ onMounted(() => {
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-medium mb-2">用户状态</label>
|
<label class="block font-medium mb-2">用户状态</label>
|
||||||
<Select v-model="statusValue" :options="statusOptions" optionLabel="label" optionValue="value"
|
<Select v-model="statusValue" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="statusLoading" fluid />
|
||||||
placeholder="选择状态" :disabled="statusLoading" fluid />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="取消" icon="pi pi-times" text @click="statusDialogVisible = false"
|
<Button label="取消" icon="pi pi-times" text @click="statusDialogVisible = false" :disabled="statusLoading" />
|
||||||
:disabled="statusLoading" />
|
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusLoading" :disabled="!statusValue" />
|
||||||
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusLoading"
|
|
||||||
:disabled="!statusValue" />
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user