Compare commits
3 Commits
9efd188628
...
920bbc4c5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 920bbc4c5a | |||
| 2f03dcf8d8 | |||
| e4c8deaacf |
@@ -13,7 +13,8 @@ type TenantFilter struct {
|
|||||||
requests.Pagination
|
requests.Pagination
|
||||||
requests.SortQueryFilter
|
requests.SortQueryFilter
|
||||||
|
|
||||||
Name *string `json:"name,omitempty" query:"name"`
|
Name *string `json:"name,omitempty" query:"name"`
|
||||||
|
Status *string `json:"status,omitempty" query:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TenantItem struct {
|
type TenantItem struct {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type UserPageFilter struct {
|
|||||||
requests.SortQueryFilter
|
requests.SortQueryFilter
|
||||||
|
|
||||||
Username *string `query:"username"`
|
Username *string `query:"username"`
|
||||||
|
Status *string `query:"status"`
|
||||||
TenantID *int64 `query:"tenant_id"`
|
TenantID *int64 `query:"tenant_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,3 +24,9 @@ type UserItem struct {
|
|||||||
type UserStatusUpdateForm struct {
|
type UserStatusUpdateForm struct {
|
||||||
Status consts.UserStatus `json:"status" validate:"required,oneof=normal disabled"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
r.user.list,
|
r.user.list,
|
||||||
Query[dto.UserPageFilter]("filter"),
|
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")
|
r.log.Debugf("Registering route: Get /super/v1/users/statuses -> user.statusList")
|
||||||
router.Get("/super/v1/users/statuses", DataFunc0(
|
router.Get("/super/v1/users/statuses", DataFunc0(
|
||||||
r.user.statusList,
|
r.user.statusList,
|
||||||
|
|||||||
@@ -61,3 +61,18 @@ func (*user) statusList(ctx fiber.Ctx) ([]requests.KV, error) {
|
|||||||
return requests.NewKV(item.String(), item.Description())
|
return requests.NewKV(item.String(), item.Description())
|
||||||
}), nil
|
}), 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,3 +107,19 @@ func (t *user) UpdateStatus(ctx context.Context, userID int64, status consts.Use
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ const docTemplate = `{
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"name": "page",
|
"name": "page",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -243,6 +248,11 @@ const docTemplate = `{
|
|||||||
"name": "page",
|
"name": "page",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"name": "tenantID",
|
"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": {
|
"/super/v1/users/statuses": {
|
||||||
"get": {
|
"get": {
|
||||||
"consumes": [
|
"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": {
|
"dto.UserStatusUpdateForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -87,6 +87,11 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"name": "page",
|
"name": "page",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -237,6 +242,11 @@
|
|||||||
"name": "page",
|
"name": "page",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"name": "tenantID",
|
"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": {
|
"/super/v1/users/statuses": {
|
||||||
"get": {
|
"get": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -530,6 +565,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dto.UserStatistics": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/definitions/consts.UserStatus"
|
||||||
|
},
|
||||||
|
"status_description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dto.UserStatusUpdateForm": {
|
"dto.UserStatusUpdateForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ definitions:
|
|||||||
verified_at:
|
verified_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
dto.UserStatistics:
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
$ref: '#/definitions/consts.UserStatus'
|
||||||
|
status_description:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
dto.UserStatusUpdateForm:
|
dto.UserStatusUpdateForm:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
@@ -288,6 +297,9 @@ paths:
|
|||||||
- in: query
|
- in: query
|
||||||
name: page
|
name: page
|
||||||
type: integer
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -382,6 +394,9 @@ paths:
|
|||||||
- in: query
|
- in: query
|
||||||
name: page
|
name: page
|
||||||
type: integer
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
type: string
|
||||||
- in: query
|
- in: query
|
||||||
name: tenantID
|
name: tenantID
|
||||||
type: integer
|
type: integer
|
||||||
@@ -426,6 +441,22 @@ paths:
|
|||||||
summary: 更新用户状态
|
summary: 更新用户状态
|
||||||
tags:
|
tags:
|
||||||
- Super
|
- 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:
|
/super/v1/users/statuses:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
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-PcBKlZrK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-B0E0aOXs.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BDK8BdlV.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
24
frontend/superadmin/src/components/SearchField.vue
Normal file
24
frontend/superadmin/src/components/SearchField.vue
Normal 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>
|
||||||
44
frontend/superadmin/src/components/SearchPanel.vue
Normal file
44
frontend/superadmin/src/components/SearchPanel.vue
Normal 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>
|
||||||
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,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { TenantService } from '@/service/TenantService';
|
import { TenantService } from '@/service/TenantService';
|
||||||
|
import SearchField from '@/components/SearchField.vue';
|
||||||
|
import SearchPanel from '@/components/SearchPanel.vue';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
@@ -198,21 +200,20 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<h4 class="m-0">租户列表</h4>
|
||||||
<h4 class="m-0">租户列表</h4>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
||||||
|
<SearchField label="名称 / Code">
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon>
|
<InputIcon>
|
||||||
<i class="pi pi-search" />
|
<i class="pi pi-search" />
|
||||||
</InputIcon>
|
</InputIcon>
|
||||||
<InputText v-model="keyword" placeholder="名称 / Code" @keyup.enter="onSearch" />
|
<InputText v-model="keyword" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
|
||||||
</IconField>
|
</IconField>
|
||||||
<Button label="查询" icon="pi pi-search" severity="secondary" @click="onSearch" />
|
</SearchField>
|
||||||
<Button label="重置" icon="pi pi-refresh" severity="secondary" @click="onReset" />
|
</SearchPanel>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:value="tenants"
|
:value="tenants"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup>
|
<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 { 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 +49,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 +136,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,27 +196,28 @@ 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 items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<h4 class="m-0">用户列表</h4>
|
||||||
<h4 class="m-0">用户列表</h4>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
||||||
|
<SearchField label="用户名">
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon>
|
<InputIcon>
|
||||||
<i class="pi pi-search" />
|
<i class="pi pi-search" />
|
||||||
</InputIcon>
|
</InputIcon>
|
||||||
<InputText v-model="username" placeholder="用户名" @keyup.enter="onSearch" />
|
<InputText v-model="username" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
|
||||||
</IconField>
|
</IconField>
|
||||||
<Button label="查询" icon="pi pi-search" severity="secondary" @click="onSearch" />
|
</SearchField>
|
||||||
<Button label="重置" icon="pi pi-refresh" severity="secondary" @click="onReset" />
|
</SearchPanel>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable :value="users" dataKey="id" :loading="loading" lazy :paginator="true" :rows="rows"
|
<DataTable :value="users" dataKey="id" :loading="loading" lazy :paginator="true" :rows="rows"
|
||||||
:totalRecords="totalRecords" :first="(page - 1) * rows" :rowsPerPageOptions="[10, 20, 50, 100]"
|
:totalRecords="totalRecords" :first="(page - 1) * rows" :rowsPerPageOptions="[10, 20, 50, 100]"
|
||||||
|
|||||||
Reference in New Issue
Block a user