This commit is contained in:
@@ -195,6 +195,21 @@ func (ctl *posts) Show(ctx fiber.Ctx, post *models.Post) (*PostItem, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buyers
|
||||||
|
//
|
||||||
|
// @Summary 作品购买人列表
|
||||||
|
// @Tags Admin Posts
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int64 true "作品 ID"
|
||||||
|
// @Param pagination query requests.Pagination false "分页参数"
|
||||||
|
// @Success 200 {object} requests.Pager{items=dto.PostBuyerItem} "成功"
|
||||||
|
// @Router /admin/v1/posts/:id/buyers [get]
|
||||||
|
// @Bind post path key(id) model(id)
|
||||||
|
// @Bind pagination query
|
||||||
|
func (ctl *posts) Buyers(ctx fiber.Ctx, post *models.Post, pagination *requests.Pagination) (*requests.Pager, error) {
|
||||||
|
return services.Posts.Buyers(ctx, post.ID, pagination)
|
||||||
|
}
|
||||||
|
|
||||||
// SendTo
|
// SendTo
|
||||||
//
|
//
|
||||||
// @Summary 赠送作品给用户
|
// @Summary 赠送作品给用户
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
return models.PostQuery.WithContext(ctx).Where(field.NewUnsafeFieldRaw("id = ?", v)).First()
|
return models.PostQuery.WithContext(ctx).Where(field.NewUnsafeFieldRaw("id = ?", v)).First()
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
r.log.Debugf("Registering route: Get /admin/v1/posts/:id/buyers -> posts.Buyers")
|
||||||
|
router.Get("/admin/v1/posts/:id/buyers"[len(r.Path()):], DataFunc2(
|
||||||
|
r.posts.Buyers,
|
||||||
|
func(ctx fiber.Ctx) (*models.Post, error) {
|
||||||
|
v := fiber.Params[int](ctx, "id")
|
||||||
|
return models.PostQuery.WithContext(ctx).Where(field.NewUnsafeFieldRaw("id = ?", v)).First()
|
||||||
|
},
|
||||||
|
Query[requests.Pagination]("pagination"),
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Post /admin/v1/posts -> posts.Create")
|
r.log.Debugf("Registering route: Post /admin/v1/posts -> posts.Create")
|
||||||
router.Post("/admin/v1/posts"[len(r.Path()):], Func1(
|
router.Post("/admin/v1/posts"[len(r.Path()):], Func1(
|
||||||
r.posts.Create,
|
r.posts.Create,
|
||||||
|
|||||||
17
backend_v1/app/http/dto/post_buyer.go
Normal file
17
backend_v1/app/http/dto/post_buyer.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type PostBuyerItem struct {
|
||||||
|
UserID int64 `json:"user_id"` // 用户 ID(购买人唯一标识,用于管理端关联用户详情/后续操作)
|
||||||
|
|
||||||
|
Username string `json:"username"` // 用户名(购买人展示名称;可能为空或默认值,前端需兼容)
|
||||||
|
|
||||||
|
Avatar string `json:"avatar"` // 用户头像 URL(用于列表展示;可能为空,前端需提供占位图/降级展示)
|
||||||
|
|
||||||
|
Phone string `json:"phone"` // 用户手机号(管理端可见;用于客服联系/核对身份,可能为空)
|
||||||
|
|
||||||
|
BoughtAt time.Time `json:"bought_at"` // 购买时间(以 user_posts.created_at 为准;用于排序/审计)
|
||||||
|
|
||||||
|
Price int64 `json:"price"` // 购买价格(单位:分;-1 表示管理员赠送/免费,非负为实际支付金额)
|
||||||
|
}
|
||||||
@@ -127,6 +127,62 @@ func (m *posts) Bought(ctx context.Context, userId int64, pagination *requests.P
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buyers 获取某个作品的购买人列表(管理端使用)
|
||||||
|
func (m *posts) Buyers(ctx context.Context, postID int64, pagination *requests.Pagination) (*requests.Pager, error) {
|
||||||
|
pagination.Format()
|
||||||
|
|
||||||
|
// 先分页查询购买记录,避免一次性拉取全量 user_posts 造成内存/延迟抖动
|
||||||
|
tblUserPost, queryUserPost := models.UserPostQuery.QueryContext(ctx)
|
||||||
|
queryUserPost = queryUserPost.
|
||||||
|
Where(tblUserPost.PostID.Eq(postID)).
|
||||||
|
Order(tblUserPost.CreatedAt.Desc())
|
||||||
|
|
||||||
|
userPosts, cnt, err := queryUserPost.FindByPage(int(pagination.Offset()), int(pagination.Limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(userPosts) == 0 {
|
||||||
|
return &requests.Pager{
|
||||||
|
Items: []dto.PostBuyerItem{},
|
||||||
|
Total: cnt,
|
||||||
|
Pagination: *pagination,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量回表查询用户信息,避免 N+1
|
||||||
|
userIDs := lo.Uniq(lo.Map(userPosts, func(item *models.UserPost, _ int) int64 {
|
||||||
|
return item.UserID
|
||||||
|
}))
|
||||||
|
tblUser, queryUser := models.UserQuery.QueryContext(ctx)
|
||||||
|
users, err := queryUser.Where(tblUser.ID.In(userIDs...)).Find()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userMap := lo.KeyBy(users, func(item *models.User) int64 { return item.ID })
|
||||||
|
|
||||||
|
items := make([]dto.PostBuyerItem, 0, len(userPosts))
|
||||||
|
for _, item := range userPosts {
|
||||||
|
user, ok := userMap[item.UserID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, dto.PostBuyerItem{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
Phone: user.Phone,
|
||||||
|
BoughtAt: item.CreatedAt,
|
||||||
|
Price: item.Price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &requests.Pager{
|
||||||
|
Items: items,
|
||||||
|
Total: cnt,
|
||||||
|
Pagination: *pagination,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPostsMapByIDs
|
// GetPostsMapByIDs
|
||||||
func (m *posts) GetPostsMapByIDs(ctx context.Context, ids []int64) (map[int64]*models.Post, error) {
|
func (m *posts) GetPostsMapByIDs(ctx context.Context, ids []int64) (map[int64]*models.Post, error) {
|
||||||
tbl, query := models.PostQuery.QueryContext(ctx)
|
tbl, query := models.PostQuery.QueryContext(ctx)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"quyun/v2/providers/ali"
|
||||||
|
|
||||||
"go.ipao.vip/atom"
|
"go.ipao.vip/atom"
|
||||||
"go.ipao.vip/atom/container"
|
"go.ipao.vip/atom/container"
|
||||||
"go.ipao.vip/atom/contracts"
|
"go.ipao.vip/atom/contracts"
|
||||||
@@ -52,8 +54,12 @@ func Provide(opts ...opt.Option) error {
|
|||||||
}, atom.GroupInitial); err != nil {
|
}, atom.GroupInitial); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := container.Container.Provide(func() (*users, error) {
|
if err := container.Container.Provide(func(
|
||||||
obj := &users{}
|
smsNotifyClient *ali.SMSNotifyClient,
|
||||||
|
) (*users, error) {
|
||||||
|
obj := &users{
|
||||||
|
smsNotifyClient: smsNotifyClient,
|
||||||
|
}
|
||||||
if err := obj.Prepare(); err != nil {
|
if err := obj.Prepare(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ func NewSMSNotifyClient(cfg *Config) (*SMSNotifyClient, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SMSNotifyClient) SendTo(phone string) (string, error) {
|
func (c *SMSNotifyClient) SendTo(phone string) (string, error) {
|
||||||
|
|
||||||
req := &dypnsapi20170525.SendSmsVerifyCodeRequest{
|
req := &dypnsapi20170525.SendSmsVerifyCodeRequest{
|
||||||
SignName: tea.String("速通互联验证码"),
|
SignName: tea.String("速通互联验证码"),
|
||||||
TemplateCode: tea.String("100001"),
|
TemplateCode: tea.String("100001"),
|
||||||
|
|||||||
@@ -26,4 +26,9 @@ export const postService = {
|
|||||||
sendTo(id, userId) {
|
sendTo(id, userId) {
|
||||||
return httpClient.post(`/posts/${id}/send-to/${userId}`);
|
return httpClient.post(`/posts/${id}/send-to/${userId}`);
|
||||||
},
|
},
|
||||||
}
|
getBuyers(id, { page = 1, limit = 10 } = {}) {
|
||||||
|
return httpClient.get(`/posts/${id}/buyers`, {
|
||||||
|
params: { page, limit }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ const sendDialogVisible = ref(false);
|
|||||||
const selectedPost = ref(null);
|
const selectedPost = ref(null);
|
||||||
const selectedUser = ref(null);
|
const selectedUser = ref(null);
|
||||||
|
|
||||||
|
// Buyers dialog state (购买人列表)
|
||||||
|
const buyersDialogVisible = ref(false);
|
||||||
|
const buyersPost = ref(null);
|
||||||
|
const buyersItems = ref([]);
|
||||||
|
const buyersLoading = ref(false);
|
||||||
|
const buyersFirst = ref(0);
|
||||||
|
const buyersRows = ref(10);
|
||||||
|
const buyersTotal = ref(0);
|
||||||
|
|
||||||
// 修改用户列表相关变量
|
// 修改用户列表相关变量
|
||||||
const users = ref({
|
const users = ref({
|
||||||
items: [],
|
items: [],
|
||||||
@@ -288,6 +297,48 @@ const handleSendConfirm = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatBuyerPrice = (priceCents) => {
|
||||||
|
if (priceCents < 0) return '赠送';
|
||||||
|
return formatPrice(priceCents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBuyers = async () => {
|
||||||
|
if (!buyersPost.value) return;
|
||||||
|
buyersLoading.value = true;
|
||||||
|
try {
|
||||||
|
const currentPage = (buyersFirst.value / buyersRows.value) + 1;
|
||||||
|
const response = await postService.getBuyers(buyersPost.value.id, {
|
||||||
|
page: currentPage,
|
||||||
|
limit: buyersRows.value
|
||||||
|
});
|
||||||
|
buyersItems.value = response.data.items || [];
|
||||||
|
buyersTotal.value = response.data.total || 0;
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: '错误',
|
||||||
|
detail: '加载购买人列表失败',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
buyersLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBuyers = async (post) => {
|
||||||
|
if (!post || !post.bought_count || post.bought_count <= 0) return;
|
||||||
|
buyersPost.value = post;
|
||||||
|
buyersFirst.value = 0;
|
||||||
|
buyersDialogVisible.value = true;
|
||||||
|
await fetchBuyers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBuyersPage = (event) => {
|
||||||
|
buyersFirst.value = event.first;
|
||||||
|
buyersRows.value = event.rows;
|
||||||
|
fetchBuyers();
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -363,6 +414,64 @@ const handleSendConfirm = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Buyers list dialog -->
|
||||||
|
<Dialog v-model:visible="buyersDialogVisible" modal header="购买人列表" :style="{ width: '80vw' }">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="font-bold">曲谱:</span>
|
||||||
|
{{ buyersPost?.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :value="buyersItems" :loading="buyersLoading" :paginator="true" :rows="buyersRows"
|
||||||
|
:totalRecords="buyersTotal" :lazy="true" :first="buyersFirst" @page="onBuyersPage" dataKey="user_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="username" header="用户">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="mask mask-squircle w-12 h-12">
|
||||||
|
<img :src="data.avatar" :alt="data.username" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold">{{ data.username }}</div>
|
||||||
|
<div class="text-xs text-gray-500">ID: {{ data.user_id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="phone" header="手机号" />
|
||||||
|
|
||||||
|
<Column field="price" header="价格">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatBuyerPrice(data.price) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="bought_at" header="购买时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDate(data.bought_at) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="关闭" icon="pi pi-times" @click="buyersDialogVisible = false" class="p-button-text" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex justify-between items-center mb-6 gap-4">
|
<div class="flex justify-between items-center mb-6 gap-4">
|
||||||
<h1 class="text-2xl font-semibold text-gray-800 text-nowrap">曲谱列表</h1>
|
<h1 class="text-2xl font-semibold text-gray-800 text-nowrap">曲谱列表</h1>
|
||||||
@@ -418,9 +527,9 @@ const handleSendConfirm = async () => {
|
|||||||
|
|
||||||
<Column field="bought_count" header="销售数量" sortable>
|
<Column field="bought_count" header="销售数量" sortable>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<Button v-if="data.bought_count > 0" text severity="info" class="p-0"
|
||||||
<span class="text-gray-500">{{ data.bought_count }}</span>
|
:label="data.bought_count.toString()" @click="openBuyers(data)" />
|
||||||
</div>
|
<span v-else class="text-gray-500">{{ data.bought_count }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user