Compare commits

...

3 Commits

16 changed files with 392 additions and 30 deletions

View File

@@ -14,6 +14,7 @@ type TenantFilter struct {
requests.SortQueryFilter
Name *string `json:"name,omitempty" query:"name"`
Status *string `json:"status,omitempty" query:"status"`
}
type TenantItem struct {

View File

@@ -11,6 +11,7 @@ type UserPageFilter struct {
requests.SortQueryFilter
Username *string `query:"username"`
Status *string `query:"status"`
TenantID *int64 `query:"tenant_id"`
}
@@ -23,3 +24,9 @@ type UserItem struct {
type UserStatusUpdateForm struct {
Status consts.UserStatus `json:"status" validate:"required,oneof=normal disabled"`
}
type UserStatistics struct {
Status consts.UserStatus `json:"status"`
StatusDescription string `json:"status_description"`
Count int64 `json:"count"`
}

View File

@@ -74,6 +74,10 @@ func (r *Routes) Register(router fiber.Router) {
r.user.list,
Query[dto.UserPageFilter]("filter"),
))
r.log.Debugf("Registering route: Get /super/v1/users/statistics -> user.statistics")
router.Get("/super/v1/users/statistics", DataFunc0(
r.user.statistics,
))
r.log.Debugf("Registering route: Get /super/v1/users/statuses -> user.statusList")
router.Get("/super/v1/users/statuses", DataFunc0(
r.user.statusList,

View File

@@ -61,3 +61,18 @@ func (*user) statusList(ctx fiber.Ctx) ([]requests.KV, error) {
return requests.NewKV(item.String(), item.Description())
}), nil
}
// statistics
//
// @Summary 用户统计信息
// @Tags Super
// @Accept json
// @Produce json
// @Success 200 {array} dto.UserStatistics
//
// @Router /super/v1/users/statistics [get]
// @Bind userID path
// @Bind form body
func (*user) statistics(ctx fiber.Ctx) ([]*dto.UserStatistics, error) {
return services.User.Statistics(ctx)
}

View File

@@ -107,3 +107,19 @@ func (t *user) UpdateStatus(ctx context.Context, userID int64, status consts.Use
return nil
}
// Statistics
func (t *user) Statistics(ctx context.Context) ([]*dto.UserStatistics, error) {
tbl, query := models.UserQuery.QueryContext(ctx)
var statistics []*dto.UserStatistics
err := query.Select(tbl.Status, tbl.ID.Count().As("count")).Group(tbl.Status).Scan(&statistics)
if err != nil {
return nil, err
}
return lo.Map(statistics, func(item *dto.UserStatistics, _ int) *dto.UserStatistics {
item.StatusDescription = item.Status.Description()
return item
}), nil
}

View File

@@ -261,3 +261,15 @@ func (t *UserTestSuite) Test_Relations() {
})
})
}
func (t *UserTestSuite) Test_Statistics() {
Convey("test page", t.T(), func() {
Convey("filter tenant users", func() {
m, err := User.Statistics(t.T().Context())
So(err, ShouldBeNil)
// So(m.OwnedTenant, ShouldNotBeNil)
// So(m.Tenants, ShouldHaveLength, 10)
t.T().Logf("%s", utils.MustJsonString(m))
})
})
}

View File

@@ -93,6 +93,11 @@ const docTemplate = `{
"type": "integer",
"name": "page",
"in": "query"
},
{
"type": "string",
"name": "status",
"in": "query"
}
],
"responses": {
@@ -243,6 +248,11 @@ const docTemplate = `{
"name": "page",
"in": "query"
},
{
"type": "string",
"name": "status",
"in": "query"
},
{
"type": "integer",
"name": "tenantID",
@@ -276,6 +286,31 @@ const docTemplate = `{
}
}
},
"/super/v1/users/statistics": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Super"
],
"summary": "用户统计信息",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.UserStatistics"
}
}
}
}
}
},
"/super/v1/users/statuses": {
"get": {
"consumes": [
@@ -536,6 +571,20 @@ const docTemplate = `{
}
}
},
"dto.UserStatistics": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/consts.UserStatus"
},
"status_description": {
"type": "string"
}
}
},
"dto.UserStatusUpdateForm": {
"type": "object",
"required": [

View File

@@ -87,6 +87,11 @@
"type": "integer",
"name": "page",
"in": "query"
},
{
"type": "string",
"name": "status",
"in": "query"
}
],
"responses": {
@@ -237,6 +242,11 @@
"name": "page",
"in": "query"
},
{
"type": "string",
"name": "status",
"in": "query"
},
{
"type": "integer",
"name": "tenantID",
@@ -270,6 +280,31 @@
}
}
},
"/super/v1/users/statistics": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Super"
],
"summary": "用户统计信息",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.UserStatistics"
}
}
}
}
}
},
"/super/v1/users/statuses": {
"get": {
"consumes": [
@@ -530,6 +565,20 @@
}
}
},
"dto.UserStatistics": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/consts.UserStatus"
},
"status_description": {
"type": "string"
}
}
},
"dto.UserStatusUpdateForm": {
"type": "object",
"required": [

View File

@@ -134,6 +134,15 @@ definitions:
verified_at:
type: string
type: object
dto.UserStatistics:
properties:
count:
type: integer
status:
$ref: '#/definitions/consts.UserStatus'
status_description:
type: string
type: object
dto.UserStatusUpdateForm:
properties:
status:
@@ -288,6 +297,9 @@ paths:
- in: query
name: page
type: integer
- in: query
name: status
type: string
produces:
- application/json
responses:
@@ -382,6 +394,9 @@ paths:
- in: query
name: page
type: integer
- in: query
name: status
type: string
- in: query
name: tenantID
type: integer
@@ -426,6 +441,22 @@ paths:
summary: 更新用户状态
tags:
- Super
/super/v1/users/statistics:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/dto.UserStatistics'
type: array
summary: 用户统计信息
tags:
- Super
/super/v1/users/statuses:
get:
consumes:

View File

@@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sakai Vue</title>
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
<script type="module" crossorigin src="./assets/index-VWI_AnCM.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B0E0aOXs.css">
<script type="module" crossorigin src="./assets/index-PcBKlZrK.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BDK8BdlV.css">
</head>
<body>

View File

@@ -0,0 +1,24 @@
<script setup>
const props = defineProps({
label: { type: String, required: true },
forId: { type: String, default: '' },
colClass: { type: String, default: 'col-span-12 md:col-span-6 lg:col-span-4' },
labelClass: { type: String, default: 'w-28 text-right' }
});
</script>
<template>
<div :class="props.colClass">
<div class="flex items-center gap-3">
<label v-if="props.forId" :for="props.forId"
class="text-sm font-medium text-surface-900 dark:text-surface-0" :class="props.labelClass">
{{ props.label }}
</label>
<span v-else class="text-sm font-medium text-surface-900 dark:text-surface-0" :class="props.labelClass">{{
props.label }}</span>
<div class="flex-1 min-w-0">
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { Comment, computed, ref, useSlots } from 'vue';
const props = defineProps({
collapsedCount: { type: Number, default: 3 },
initialExpanded: { type: Boolean, default: false },
loading: { type: Boolean, default: false }
});
const emit = defineEmits(['search', 'reset']);
const slots = useSlots();
const expanded = ref(props.initialExpanded);
const fieldNodes = computed(() => {
const nodes = slots.default ? slots.default() : [];
return (nodes || []).filter((n) => n && n.type !== Comment);
});
const canToggle = computed(() => fieldNodes.value.length > props.collapsedCount);
const visibleFields = computed(() => (expanded.value ? fieldNodes.value : fieldNodes.value.slice(0, props.collapsedCount)));
function onToggle() {
expanded.value = !expanded.value;
}
</script>
<template>
<div class="mb-4 p-4 rounded border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900">
<div class="flex flex-col lg:flex-row lg:items-end gap-4 lg:gap-6">
<div class="flex-1 grid grid-cols-12 gap-x-6 gap-y-4 items-end">
<template v-for="(node, idx) in visibleFields" :key="idx">
<component :is="node" />
</template>
</div>
<div class="flex items-center justify-end gap-2 lg:flex-none">
<Button label="查询" icon="pi pi-search" :loading="props.loading" @click="emit('search')" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" outlined :disabled="props.loading" @click="emit('reset')" />
<Button v-if="canToggle" :label="expanded ? '收起' : '展开'" :icon="expanded ? 'pi pi-angle-up' : 'pi pi-angle-down'" iconPos="right" severity="secondary" text :disabled="props.loading" @click="onToggle" />
</div>
</div>
</div>
</template>

View 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>

View File

@@ -23,13 +23,37 @@ export const UserService = {
};
},
async getUserStatuses() {
try {
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 }) {
return requestJson(`/super/v1/users/${userID}/status`, {
method: 'PATCH',
body: { status }
});
try {
return await requestJson(`/super/v1/user/${userID}/status`, { method: 'PATCH', 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;
}
}
};

View File

@@ -1,5 +1,7 @@
<script setup>
import { TenantService } from '@/service/TenantService';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
@@ -198,21 +200,20 @@ onMounted(() => {
<template>
<div>
<div class="card">
<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 justify-between mb-4">
<h4 class="m-0">租户列表</h4>
</div>
<div class="flex flex-wrap items-center gap-2">
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="名称 / Code">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="keyword" placeholder="名称 / Code" @keyup.enter="onSearch" />
<InputText v-model="keyword" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
</IconField>
<Button label="查询" icon="pi pi-search" severity="secondary" @click="onSearch" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" @click="onReset" />
</div>
</div>
</SearchField>
</SearchPanel>
<DataTable
:value="tenants"

View File

@@ -1,7 +1,10 @@
<script setup>
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { UserService } from '@/service/UserService';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
const toast = useToast();
@@ -46,6 +49,57 @@ const statusOptions = ref([]);
const statusUser = 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() {
if (statusOptions.value.length > 0) return;
const list = await UserService.getUserStatuses();
@@ -82,6 +136,7 @@ async function confirmUpdateStatus() {
toast.add({ severity: 'success', summary: '更新成功', detail: `用户ID: ${userID}`, life: 3000 });
statusDialogVisible.value = false;
await loadUsers();
await loadStatistics();
} catch (error) {
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新用户状态', life: 4000 });
} finally {
@@ -141,27 +196,28 @@ function onSort(event) {
onMounted(() => {
loadUsers();
loadStatistics();
});
</script>
<template>
<div>
<StatisticsStrip :items="statisticsItems" containerClass="card mb-4" />
<div class="card">
<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 justify-between mb-4">
<h4 class="m-0">用户列表</h4>
</div>
<div class="flex flex-wrap items-center gap-2">
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="用户名">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="username" placeholder="用户名" @keyup.enter="onSearch" />
<InputText v-model="username" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
</IconField>
<Button label="查询" icon="pi pi-search" severity="secondary" @click="onSearch" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" @click="onReset" />
</div>
</div>
</SearchField>
</SearchPanel>
<DataTable :value="users" dataKey="id" :loading="loading" lazy :paginator="true" :rows="rows"
:totalRecords="totalRecords" :first="(page - 1) * rows" :rowsPerPageOptions="[10, 20, 50, 100]"