diff --git a/backend_v1/app/http/admin/users.go b/backend_v1/app/http/admin/users.go index 5db91db..cca5eed 100644 --- a/backend_v1/app/http/admin/users.go +++ b/backend_v1/app/http/admin/users.go @@ -7,22 +7,53 @@ import ( "quyun/v2/database/models" "github.com/gofiber/fiber/v3" + "github.com/samber/lo" ) // @provider type users struct{} +type UserItem struct { + *models.User + BoughtCount int64 `json:"bought_count"` // 用户已购作品数量(统计 user_posts 记录数,含赠送/免费购买) +} + // List users // // @Summary 用户列表 // @Tags Admin Users // @Produce json // @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] // @Bind query query 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 diff --git a/backend_v1/app/services/users.go b/backend_v1/app/services/users.go index 20417de..becd6f3 100644 --- a/backend_v1/app/services/users.go +++ b/backend_v1/app/services/users.go @@ -72,6 +72,37 @@ func (m *users) List( }, 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 func (m *users) PostList(ctx context.Context, userId int64, filter *dto.PostListQuery) (*requests.Pager, error) { filter.Format() diff --git a/frontend/admin/src/pages/UserDetail.vue b/frontend/admin/src/pages/UserDetail.vue index 8b9603e..a2adc9a 100644 --- a/frontend/admin/src/pages/UserDetail.vue +++ b/frontend/admin/src/pages/UserDetail.vue @@ -3,8 +3,6 @@ import { userService } from '@/api/userService'; import { formatDate } from '@/utils/date'; import Badge from 'primevue/badge'; import Button from 'primevue/button'; -import Column from 'primevue/column'; -import DataTable from 'primevue/datatable'; import ProgressSpinner from 'primevue/progressspinner'; import { onMounted, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; @@ -13,12 +11,6 @@ const route = useRoute(); const router = useRouter(); const loading = ref(false); const user = ref(null); -const userArticles = ref([]); -const totalArticles = ref(0); -const lazyParams = ref({ - page: 1, - limit: 10 -}); const fetchUserDetail = async () => { 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 = () => { router.push('/users'); }; -const onPage = (event) => { - lazyParams.value = { - page: event.page + 1, - limit: event.rows - }; - fetchUserArticles(); -}; - onMounted(() => { fetchUserDetail(); - fetchUserArticles(); }); - -const formatPrice = (price) => { - return (price / 100).toFixed(2); -}; @@ -121,25 +86,6 @@ const formatPrice = (price) => { - - - 购买的曲谱 - - - - - ¥ {{ formatPrice(data.price) }} - - - - - {{ formatDate(data.bought_at) }} - - - - diff --git a/frontend/admin/src/pages/UserPage.vue b/frontend/admin/src/pages/UserPage.vue index 4dd0216..11ec802 100644 --- a/frontend/admin/src/pages/UserPage.vue +++ b/frontend/admin/src/pages/UserPage.vue @@ -42,6 +42,14 @@ const phoneSaving = ref(false); const phoneTargetUser = ref(null); 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 () => { loading.value = true; try { @@ -114,6 +122,47 @@ const openPhoneDialog = (user) => { 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 () => { if (!phoneTargetUser.value) return; const phone = normalizePhone(phoneInput.value); @@ -144,6 +193,50 @@ onMounted(() => { + + + + + 用户:{{ articlesUser.username }}(ID: {{ articlesUser.id }}) + + + + + 暂无购买记录 + + + + + + 加载购买数据... + + + + + + + + {{ formatBoughtPrice(data.price) }} + + + + + + {{ formatDate(data.bought_at) }} + + + + + + + + + + + + @@ -221,6 +314,14 @@ onMounted(() => { + + + + {{ data.bought_count || 0 }} + + +