Compare commits

..

3 Commits

Author SHA1 Message Date
1aab961b8d feat: 添加购买记录分页功能
Some checks failed
build quyun / Build (push) Failing after 1m24s
2025-12-20 23:34:59 +08:00
661a595fa7 feat: 添加仅已购用户筛选功能 2025-12-20 23:30:51 +08:00
257c9a286a feat: 添加用户购买作品数量统计功能 2025-12-20 23:23:04 +08:00
7 changed files with 234 additions and 64 deletions

View File

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

View File

@@ -4,5 +4,7 @@ import "quyun/v2/app/requests"
type UserListQuery struct {
*requests.Pagination
Keyword *string `query:"keyword"`
Keyword *string `query:"keyword"` // 关键词(模糊匹配手机号/用户名;若为数字且>0则同时按用户 ID 精确匹配)
OnlyBought *bool `query:"onlyBought"` // 是否仅返回“购买数量>0”的用户true=仅已购用户false/空=全部用户)
}

View File

@@ -115,6 +115,8 @@ func (m *posts) Bought(ctx context.Context, userId int64, pagination *requests.P
RightJoin(tblUserPost, tbl.ID.EqCol(tblUserPost.PostID)).
Where(tblUserPost.UserID.Eq(userId)).
Order(tblUserPost.CreatedAt.Desc()).
Limit(int(pagination.Limit)).
Offset(int(pagination.Offset())).
Scan(&retItems)
if err != nil {
return nil, err

View File

@@ -50,16 +50,57 @@ func (m *users) List(
query = query.Order(tbl.ID.Desc())
keyword := ""
if filter.Keyword != nil && *filter.Keyword != "" {
keyword = strings.TrimSpace(*filter.Keyword)
query = query.
Where(tbl.Phone.Like(database.WrapLike(*filter.Keyword))).
Or(tbl.Username.Like(database.WrapLike(*filter.Keyword)))
Where(tbl.Phone.Like(database.WrapLike(keyword))).
Or(tbl.Username.Like(database.WrapLike(keyword)))
if id, err := strconv.ParseInt(strings.TrimSpace(*filter.Keyword), 10, 64); err == nil && id > 0 {
if id, err := strconv.ParseInt(keyword, 10, 64); err == nil && id > 0 {
query = query.Or(tbl.ID.Eq(id))
}
}
if filter.OnlyBought != nil && *filter.OnlyBought {
// 仅返回“购买数量>0”的用户通过 JOIN user_posts 做存在性过滤。
// 注意FindByPage 内部用 Count(),在 GROUP BY 场景下可能不准确,这里改为手动 Count(DISTINCT users.id)。
tblUserPost, _ := models.UserPostQuery.QueryContext(ctx)
query = query.Join(tblUserPost, tbl.ID.EqCol(tblUserPost.UserID)).Group(tbl.ID)
offset := int(filter.Pagination.Offset())
limit := int(filter.Pagination.Limit)
items, err := query.Offset(offset).Limit(limit).Find()
if err != nil {
return nil, errors.Wrap(err, "query users error")
}
db := _db.WithContext(ctx).Model(&models.User{}).
Joins("JOIN user_posts ON user_posts.user_id = users.id")
if keyword != "" {
like := database.WrapLike(keyword)
args := []any{like, like}
where := "(users.phone LIKE ? OR users.username LIKE ?)"
if id, err := strconv.ParseInt(keyword, 10, 64); err == nil && id > 0 {
where = "(users.phone LIKE ? OR users.username LIKE ? OR users.id = ?)"
args = append(args, id)
}
db = db.Where(where, args...)
}
var cnt int64
if err := db.Distinct("users.id").Count(&cnt).Error; err != nil {
return nil, errors.Wrap(err, "count users error")
}
return &requests.Pager{
Items: items,
Total: cnt,
Pagination: *filter.Pagination,
}, nil
}
items, cnt, err := query.FindByPage(int(filter.Pagination.Offset()), int(filter.Pagination.Limit))
if err != nil {
return nil, errors.Wrap(err, "query users error")
@@ -72,6 +113,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()

View File

@@ -1,12 +1,13 @@
import httpClient from './httpClient';
export const userService = {
getUsers({ page = 1, limit = 10, keyword = '' } = {}) {
getUsers({ page = 1, limit = 10, keyword = '', onlyBought = false } = {}) {
return httpClient.get('/users', {
params: {
page,
limit,
keyword: keyword.trim()
keyword: keyword.trim(),
onlyBought
}
});
},

View File

@@ -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);
};
</script>
<template>
@@ -121,25 +86,6 @@ const formatPrice = (price) => {
</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 }">
&yen; {{ formatPrice(data.price) }}
</template>
</Column>
<Column field="bought_at" header="购买时间">
<template #body="{ data }">
{{ formatDate(data.bought_at) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>

View File

@@ -7,6 +7,7 @@ import Column from 'primevue/column';
import ConfirmDialog from 'primevue/confirmdialog';
import DataTable from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import ProgressSpinner from 'primevue/progressspinner';
import Toast from 'primevue/toast';
@@ -25,6 +26,12 @@ const filters = ref({
status: { value: null, matchMode: 'equals' }
});
const boughtFilterOptions = ref([
{ name: '全部用户', value: false },
{ name: '仅已购用户', value: true }
]);
const onlyBought = ref(false);
// Table state
const users = ref({
items: [],
@@ -42,6 +49,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 {
@@ -50,7 +65,8 @@ const fetchUsers = async () => {
const response = await userService.getUsers({
page: currentPage,
limit: rows.value,
keyword: globalFilterValue.value
keyword: globalFilterValue.value,
onlyBought: onlyBought.value
});
users.value = response.data;
} catch (error) {
@@ -114,6 +130,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);
@@ -139,11 +196,60 @@ const savePhone = async () => {
onMounted(() => {
fetchUsers();
});
const onOnlyBoughtChange = () => {
first.value = 0;
fetchUsers();
};
</script>
<template>
<Toast />
<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' }">
<div class="space-y-3">
<div class="text-sm text-gray-600" v-if="phoneTargetUser">
@@ -170,7 +276,9 @@ onMounted(() => {
</div>
<div class="card">
<div class="pb-10 flex">
<div class="pb-10 flex gap-3 items-center">
<Dropdown v-model="onlyBought" :options="boughtFilterOptions" optionLabel="name" optionValue="value"
class="w-48" @change="onOnlyBoughtChange" />
<InputText v-model="globalFilterValue" placeholder="搜索用户..." class="flex-1" @input="onSearch" />
</div>
@@ -221,6 +329,14 @@ onMounted(() => {
<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>
<template #body="{ data }">
<Badge :value="data.status === 0 ? '活跃' : '禁用'"