feat: 添加用户购买作品数量统计功能
This commit is contained in:
@@ -7,22 +7,53 @@ import (
|
|||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @provider
|
// @provider
|
||||||
type users struct{}
|
type users struct{}
|
||||||
|
|
||||||
|
type UserItem struct {
|
||||||
|
*models.User
|
||||||
|
BoughtCount int64 `json:"bought_count"` // 用户已购作品数量(统计 user_posts 记录数,含赠送/免费购买)
|
||||||
|
}
|
||||||
|
|
||||||
// List users
|
// List users
|
||||||
//
|
//
|
||||||
// @Summary 用户列表
|
// @Summary 用户列表
|
||||||
// @Tags Admin Users
|
// @Tags Admin Users
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param query query UserListQuery false "筛选条件"
|
// @Param query query UserListQuery false "筛选条件"
|
||||||
// @Success 200 {object} requests.Pager{items=models.User} "成功"
|
// @Success 200 {object} requests.Pager{items=UserItem} "成功"
|
||||||
// @Router /admin/v1/users [get]
|
// @Router /admin/v1/users [get]
|
||||||
// @Bind query query
|
// @Bind query query
|
||||||
func (ctl *users) List(ctx fiber.Ctx, query *dto.UserListQuery) (*requests.Pager, error) {
|
func (ctl *users) List(ctx fiber.Ctx, query *dto.UserListQuery) (*requests.Pager, error) {
|
||||||
return services.Users.List(ctx, query)
|
pager, err := services.Users.List(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := lo.Map(pager.Items.([]*models.User), func(item *models.User, _ int) int64 {
|
||||||
|
return item.ID
|
||||||
|
})
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return pager, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cntMap, err := services.Users.BoughtStatistics(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return pager, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := lo.Map(pager.Items.([]*models.User), func(item *models.User, _ int) UserItem {
|
||||||
|
cnt := int64(0)
|
||||||
|
if v, ok := cntMap[item.ID]; ok {
|
||||||
|
cnt = v
|
||||||
|
}
|
||||||
|
return UserItem{User: item, BoughtCount: cnt}
|
||||||
|
})
|
||||||
|
pager.Items = items
|
||||||
|
return pager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show user
|
// Show user
|
||||||
|
|||||||
@@ -72,6 +72,37 @@ func (m *users) List(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoughtStatistics 获取指定用户 ID 的购买作品数量(仅统计 user_posts 记录数)。
|
||||||
|
func (m *users) BoughtStatistics(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return map[int64]int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端用户列表需要展示购买数量;这里用 group by 聚合,避免 N+1。
|
||||||
|
tbl, query := models.UserPostQuery.QueryContext(ctx)
|
||||||
|
|
||||||
|
var items []struct {
|
||||||
|
Count int64
|
||||||
|
UserID int64
|
||||||
|
}
|
||||||
|
if err := query.
|
||||||
|
Select(
|
||||||
|
tbl.UserID.Count().As("count"),
|
||||||
|
tbl.UserID,
|
||||||
|
).
|
||||||
|
Where(tbl.UserID.In(userIDs...)).
|
||||||
|
Group(tbl.UserID).
|
||||||
|
Scan(&items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[int64]int64, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
result[item.UserID] = item.Count
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PostList returns a paginated list of posts for a user
|
// PostList returns a paginated list of posts for a user
|
||||||
func (m *users) PostList(ctx context.Context, userId int64, filter *dto.PostListQuery) (*requests.Pager, error) {
|
func (m *users) PostList(ctx context.Context, userId int64, filter *dto.PostListQuery) (*requests.Pager, error) {
|
||||||
filter.Format()
|
filter.Format()
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { userService } from '@/api/userService';
|
|||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
import Badge from 'primevue/badge';
|
import Badge from 'primevue/badge';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Column from 'primevue/column';
|
|
||||||
import DataTable from 'primevue/datatable';
|
|
||||||
import ProgressSpinner from 'primevue/progressspinner';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
@@ -13,12 +11,6 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const user = ref(null);
|
const user = ref(null);
|
||||||
const userArticles = ref([]);
|
|
||||||
const totalArticles = ref(0);
|
|
||||||
const lazyParams = ref({
|
|
||||||
page: 1,
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchUserDetail = async () => {
|
const fetchUserDetail = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -32,42 +24,15 @@ const fetchUserDetail = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUserArticles = async () => {
|
|
||||||
try {
|
|
||||||
const response = await userService.getUserArticles(
|
|
||||||
route.params.id,
|
|
||||||
lazyParams.value.page,
|
|
||||||
lazyParams.value.limit
|
|
||||||
);
|
|
||||||
userArticles.value = response.data.items;
|
|
||||||
totalArticles.value = response.data.total;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user articles:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
router.push('/users');
|
router.push('/users');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPage = (event) => {
|
|
||||||
lazyParams.value = {
|
|
||||||
page: event.page + 1,
|
|
||||||
limit: event.rows
|
|
||||||
};
|
|
||||||
fetchUserArticles();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchUserDetail();
|
fetchUserDetail();
|
||||||
fetchUserArticles();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const formatPrice = (price) => {
|
|
||||||
return (price / 100).toFixed(2);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -121,25 +86,6 @@ const formatPrice = (price) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户购买的曲谱列表 -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="text-xl font-semibold mb-4">购买的曲谱</h3>
|
|
||||||
<DataTable :value="userArticles" stripedRows class="p-datatable-sm" responsiveLayout="scroll"
|
|
||||||
:lazy="true" :totalRecords="totalArticles" :rows="lazyParams.limit" :loading="loading"
|
|
||||||
@page="onPage" paginator :rows-per-page-options="[10, 20, 50]">
|
|
||||||
<Column field="title" header="标题"></Column>
|
|
||||||
<Column field="price" header="价格">
|
|
||||||
<template #body="{ data }">
|
|
||||||
¥ {{ formatPrice(data.price) }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="bought_at" header="购买时间">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ formatDate(data.bought_at) }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ const phoneSaving = ref(false);
|
|||||||
const phoneTargetUser = ref(null);
|
const phoneTargetUser = ref(null);
|
||||||
const phoneInput = ref('');
|
const phoneInput = ref('');
|
||||||
|
|
||||||
|
const articlesDialogVisible = ref(false);
|
||||||
|
const articlesLoading = ref(false);
|
||||||
|
const articlesUser = ref(null);
|
||||||
|
const articlesItems = ref([]);
|
||||||
|
const articlesFirst = ref(0);
|
||||||
|
const articlesRows = ref(10);
|
||||||
|
const articlesTotal = ref(0);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +122,47 @@ const openPhoneDialog = (user) => {
|
|||||||
|
|
||||||
const normalizePhone = (v) => v.toString().replace(/\D/g, '').slice(0, 11);
|
const normalizePhone = (v) => v.toString().replace(/\D/g, '').slice(0, 11);
|
||||||
|
|
||||||
|
const formatMoney = (cents) => `¥${(cents / 100).toFixed(2)}`;
|
||||||
|
|
||||||
|
const formatBoughtPrice = (priceCents) => {
|
||||||
|
if (priceCents < 0) return '赠送';
|
||||||
|
return formatMoney(priceCents);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserArticles = async () => {
|
||||||
|
if (!articlesUser.value) return;
|
||||||
|
articlesLoading.value = true;
|
||||||
|
try {
|
||||||
|
const currentPage = (articlesFirst.value / articlesRows.value) + 1;
|
||||||
|
const response = await userService.getUserArticles(
|
||||||
|
articlesUser.value.id,
|
||||||
|
currentPage,
|
||||||
|
articlesRows.value
|
||||||
|
);
|
||||||
|
articlesItems.value = response.data.items || [];
|
||||||
|
articlesTotal.value = response.data.total || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user articles:', error);
|
||||||
|
toast.add({ severity: 'error', summary: '错误', detail: '加载购买作品失败', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
articlesLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUserArticles = async (user) => {
|
||||||
|
if (!user || !user.bought_count || user.bought_count <= 0) return;
|
||||||
|
articlesUser.value = user;
|
||||||
|
articlesFirst.value = 0;
|
||||||
|
articlesDialogVisible.value = true;
|
||||||
|
await fetchUserArticles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onArticlesPage = (event) => {
|
||||||
|
articlesFirst.value = event.first;
|
||||||
|
articlesRows.value = event.rows;
|
||||||
|
fetchUserArticles();
|
||||||
|
};
|
||||||
|
|
||||||
const savePhone = async () => {
|
const savePhone = async () => {
|
||||||
if (!phoneTargetUser.value) return;
|
if (!phoneTargetUser.value) return;
|
||||||
const phone = normalizePhone(phoneInput.value);
|
const phone = normalizePhone(phoneInput.value);
|
||||||
@@ -144,6 +193,50 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
|
<Dialog v-model:visible="articlesDialogVisible" modal header="已购作品" :style="{ width: '80vw' }">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-sm text-gray-600" v-if="articlesUser">
|
||||||
|
用户:{{ articlesUser.username }}(ID: {{ articlesUser.id }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :value="articlesItems" :loading="articlesLoading" :paginator="true" :rows="articlesRows"
|
||||||
|
:totalRecords="articlesTotal" :lazy="true" :first="articlesFirst" @page="onArticlesPage"
|
||||||
|
dataKey="id" class="p-datatable-sm" responsiveLayout="scroll" style="max-height: 60vh" scrollable>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center p-4">暂无购买记录</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #loading>
|
||||||
|
<div class="flex flex-col items-center justify-center p-4">
|
||||||
|
<ProgressSpinner style="width:50px;height:50px" />
|
||||||
|
<span class="mt-2">加载购买数据...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column field="title" header="标题" />
|
||||||
|
|
||||||
|
<Column field="price" header="购买价格">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatBoughtPrice(data.price) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="bought_at" header="购买时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDate(data.bought_at) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button label="关闭" text @click="articlesDialogVisible = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog v-model:visible="phoneDialogVisible" modal header="设置手机号" :style="{ width: '420px' }">
|
<Dialog v-model:visible="phoneDialogVisible" modal header="设置手机号" :style="{ width: '420px' }">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="text-sm text-gray-600" v-if="phoneTargetUser">
|
<div class="text-sm text-gray-600" v-if="phoneTargetUser">
|
||||||
@@ -221,6 +314,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
<Column field="phone" header="Phone" sortable></Column>
|
<Column field="phone" header="Phone" sortable></Column>
|
||||||
|
|
||||||
|
<Column field="bought_count" header="购买数量" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Button v-if="data.bought_count > 0" text severity="info" class="p-0"
|
||||||
|
:label="data.bought_count.toString()" @click="openUserArticles(data)" />
|
||||||
|
<span v-else class="text-gray-500">{{ data.bought_count || 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
<Column field="status" header="状态" sortable>
|
<Column field="status" header="状态" sortable>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Badge :value="data.status === 0 ? '活跃' : '禁用'"
|
<Badge :value="data.status === 0 ? '活跃' : '禁用'"
|
||||||
|
|||||||
Reference in New Issue
Block a user