feat: add post page

This commit is contained in:
yanghao05
2025-04-09 16:40:34 +08:00
parent aa8077937f
commit 6151f4e244
8 changed files with 457 additions and 91 deletions

View File

@@ -64,15 +64,14 @@ func (m *mediasModel) countByCondition(ctx context.Context, expr BoolExpression)
}
func (m *mediasModel) List(ctx context.Context, pagination *requests.Pagination) (*requests.Pager, error) {
limit := pagination.GetLimit()
offset := pagination.GetOffset()
pagination.Format()
tbl := table.Medias
stmt := tbl.
SELECT(tbl.AllColumns).
ORDER_BY(tbl.ID.DESC()).
LIMIT(limit).
OFFSET(offset)
LIMIT(pagination.Limit).
OFFSET(pagination.Offset)
m.log.Infof("sql: %s", stmt.DebugSql())
var medias []model.Medias

View File

@@ -3,6 +3,7 @@ package models
import (
"context"
"errors"
"time"
"quyun/app/requests"
"quyun/database/fields"
@@ -72,6 +73,9 @@ func (m *postsModel) GetByID(ctx context.Context, id int64) (*model.Posts, error
// Create
func (m *postsModel) Create(ctx context.Context, model *model.Posts) error {
model.CreatedAt = time.Now()
model.UpdatedAt = time.Now()
tbl := table.Posts
stmt := tbl.INSERT(tbl.MutableColumns).MODEL(model)
m.log.Infof("sql: %s", stmt.DebugSql())
@@ -86,6 +90,8 @@ func (m *postsModel) Create(ctx context.Context, model *model.Posts) error {
// Update
func (m *postsModel) Update(ctx context.Context, id int64, model *model.Posts) error {
model.UpdatedAt = time.Now()
tbl := table.Posts
stmt := tbl.UPDATE(tbl.MutableColumns).SET(model).WHERE(tbl.ID.EQ(Int64(id)))
m.log.Infof("sql: %s", stmt.DebugSql())
@@ -118,16 +124,15 @@ func (m *postsModel) countByCondition(ctx context.Context, expr BoolExpression)
}
func (m *postsModel) List(ctx context.Context, pagination *requests.Pagination, cond BoolExpression) (*requests.Pager, error) {
limit := pagination.GetLimit()
offset := pagination.GetOffset()
pagination.Format()
tbl := table.Posts
stmt := tbl.
SELECT(tbl.AllColumns).
WHERE(cond).
ORDER_BY(tbl.ID.DESC()).
LIMIT(limit).
OFFSET(offset)
LIMIT(pagination.Limit).
OFFSET(pagination.Offset)
m.log.Infof("sql: %s", stmt.DebugSql())
var posts []model.Posts

View File

@@ -2,10 +2,14 @@ package models
import (
"context"
"fmt"
"math/rand"
"testing"
"quyun/app/service/testx"
"quyun/database"
"quyun/database/fields"
"quyun/database/schemas/public/model"
"quyun/database/schemas/public/table"
. "github.com/smartystreets/goconvey/convey"
@@ -36,8 +40,37 @@ func Test_Posts(t *testing.T) {
})
}
func (s *PostsTestSuite) Test_Demo() {
func (s *PostsTestSuite) Test_BatchInsert() {
Convey("Test_Demo", s.T(), func() {
database.Truncate(context.Background(), db, table.Posts.TableName())
count := 100
for i := 0; i < count; i++ {
model := model.Posts{
Status: fields.PostStatusPublished,
Title: fmt.Sprintf("test-title-%d", i),
Description: fmt.Sprintf("test-description-%d", i),
Content: fmt.Sprintf("test-content-%d", i),
Price: rand.Int63n(10000),
Discount: int16(rand.Intn(100)),
Views: rand.Int63n(10000),
Likes: rand.Int63n(10000),
Tags: fields.ToJson([]string{"tag1", "tag2", "tag3"}),
Assets: fields.ToJson([]fields.MediaAsset{
{
Type: fields.MediaAssetTypeAudio,
Media: rand.Int63n(10000),
},
{
Type: fields.MediaAssetTypeVideo,
Media: rand.Int63n(10000),
},
}),
}
if err := Posts.Create(context.Background(), &model); err != nil {
s.T().Fatal(err)
}
}
})
}

View File

@@ -9,29 +9,19 @@ type Pager struct {
}
type Pagination struct {
Page int64 `json:"page" form:"page" query:"page"`
Limit int64 `json:"limit" form:"limit" query:"limit"`
Page int64 `json:"page" form:"page" query:"page"`
Limit int64 `json:"limit" form:"limit" query:"limit"`
Offset int64 `json:"-"`
}
func (filter *Pagination) GetOffset() int64 {
return (filter.Page - 1) * filter.Limit
}
func (filter *Pagination) GetLimit() int64 {
if filter.Limit <= 0 {
return 10
}
return filter.Limit
}
func (filter *Pagination) Format() *Pagination {
func (filter *Pagination) Format() {
if filter.Page <= 0 {
filter.Page = 1
}
if !lo.Contains([]int64{10, 20, 50, 100}, filter.Limit) {
if !lo.Contains([]int64{10, 25, 50, 100}, filter.Limit) {
filter.Limit = 10
}
return filter
filter.Offset = (filter.Page - 1) * filter.Limit
}

View File

@@ -35,4 +35,8 @@ Content-Type: application/json
### get medias
GET {{host}}/v1/admin/medias HTTP/1.1
Content-Type: application/json
### get posts
GET {{host}}/v1/admin/posts HTTP/1.1
Content-Type: application/json

View File

@@ -0,0 +1,12 @@
import httpClient from './httpClient';
export const postService = {
getPosts({ page = 1, limit = 10 } = {}) {
return httpClient.get('/admin/posts', {
params: { page, limit }
});
},
getPost(id) {
return httpClient.get(`/admin/posts/${id}`);
},
};

View File

@@ -0,0 +1,297 @@
{
"page": 1,
"limit": 10,
"total": 100,
"items": [
{
"id": 100,
"created_at": "2025-04-09T14:42:08.721793Z",
"updated_at": "2025-04-09T14:42:08.721793Z",
"deleted_at": null,
"status": 1,
"title": "test-title-99",
"description": "test-description-99",
"content": "test-content-99",
"price": 4205,
"discount": 45,
"views": 8566,
"likes": 2912,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 8775
},
{
"type": "video",
"media": 2905
}
]
},
{
"id": 99,
"created_at": "2025-04-09T14:42:08.715622Z",
"updated_at": "2025-04-09T14:42:08.715622Z",
"deleted_at": null,
"status": 1,
"title": "test-title-98",
"description": "test-description-98",
"content": "test-content-98",
"price": 8407,
"discount": 59,
"views": 4779,
"likes": 1514,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 5265
},
{
"type": "video",
"media": 9715
}
]
},
{
"id": 98,
"created_at": "2025-04-09T14:42:08.710788Z",
"updated_at": "2025-04-09T14:42:08.710788Z",
"deleted_at": null,
"status": 1,
"title": "test-title-97",
"description": "test-description-97",
"content": "test-content-97",
"price": 1314,
"discount": 15,
"views": 6962,
"likes": 440,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 1321
},
{
"type": "video",
"media": 6559
}
]
},
{
"id": 97,
"created_at": "2025-04-09T14:42:08.705116Z",
"updated_at": "2025-04-09T14:42:08.705116Z",
"deleted_at": null,
"status": 1,
"title": "test-title-96",
"description": "test-description-96",
"content": "test-content-96",
"price": 9030,
"discount": 14,
"views": 6942,
"likes": 9185,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 6482
},
{
"type": "video",
"media": 2153
}
]
},
{
"id": 96,
"created_at": "2025-04-09T14:42:08.697806Z",
"updated_at": "2025-04-09T14:42:08.697806Z",
"deleted_at": null,
"status": 1,
"title": "test-title-95",
"description": "test-description-95",
"content": "test-content-95",
"price": 6363,
"discount": 41,
"views": 8679,
"likes": 1755,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 6229
},
{
"type": "video",
"media": 3559
}
]
},
{
"id": 95,
"created_at": "2025-04-09T14:42:08.693557Z",
"updated_at": "2025-04-09T14:42:08.693557Z",
"deleted_at": null,
"status": 1,
"title": "test-title-94",
"description": "test-description-94",
"content": "test-content-94",
"price": 6819,
"discount": 61,
"views": 9728,
"likes": 1103,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 6464
},
{
"type": "video",
"media": 4964
}
]
},
{
"id": 94,
"created_at": "2025-04-09T14:42:08.688664Z",
"updated_at": "2025-04-09T14:42:08.688664Z",
"deleted_at": null,
"status": 1,
"title": "test-title-93",
"description": "test-description-93",
"content": "test-content-93",
"price": 4324,
"discount": 0,
"views": 473,
"likes": 2956,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 3043
},
{
"type": "video",
"media": 1234
}
]
},
{
"id": 93,
"created_at": "2025-04-09T14:42:08.681727Z",
"updated_at": "2025-04-09T14:42:08.681727Z",
"deleted_at": null,
"status": 1,
"title": "test-title-92",
"description": "test-description-92",
"content": "test-content-92",
"price": 8068,
"discount": 86,
"views": 3783,
"likes": 3477,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 3623
},
{
"type": "video",
"media": 2628
}
]
},
{
"id": 92,
"created_at": "2025-04-09T14:42:08.677622Z",
"updated_at": "2025-04-09T14:42:08.677622Z",
"deleted_at": null,
"status": 1,
"title": "test-title-91",
"description": "test-description-91",
"content": "test-content-91",
"price": 6313,
"discount": 43,
"views": 982,
"likes": 2660,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 3542
},
{
"type": "video",
"media": 6605
}
]
},
{
"id": 91,
"created_at": "2025-04-09T14:42:08.674006Z",
"updated_at": "2025-04-09T14:42:08.674006Z",
"deleted_at": null,
"status": 1,
"title": "test-title-90",
"description": "test-description-90",
"content": "test-content-90",
"price": 3659,
"discount": 83,
"views": 76,
"likes": 4807,
"tags": [
"tag1",
"tag2",
"tag3"
],
"assets": [
{
"type": "audio",
"media": 3191
},
{
"type": "video",
"media": 2689
}
]
}
]
}

View File

@@ -1,6 +1,8 @@
<script setup>
import { postService } from '@/api/postService'; // Assuming you have a postService for API calls
import { InputText } from 'primevue';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
import Column from 'primevue/column';
import ConfirmDialog from 'primevue/confirmdialog';
@@ -18,22 +20,6 @@ const router = useRouter();
const confirm = useConfirm();
const toast = useToast();
// State for edit dialog (removed "create" functionality since we now have a dedicated page)
const postDialog = ref(false);
const postDialogTitle = ref('编辑文章');
const editMode = ref(true); // Always true now since we only use dialog for editing
const currentPost = ref({
id: null,
title: '',
author: '',
thumbnail: '',
price: 0,
publishedAt: '',
status: '',
mediaTypes: [],
viewCount: 0
});
// Post statuses for filtering
const statusOptions = ref([
{ name: '所有状态', value: null },
@@ -50,46 +36,34 @@ const mediaTypeOptions = ref([
{ name: '音频', value: '音频' }
]);
const selectedStatus = ref(statusOptions.value[0]);
const globalFilterValue = ref('');
const loading = ref(false);
// Sample data - in a real app, this would come from an API
const posts = ref([
{
id: 1,
title: '如何高效学习编程',
author: '张三',
thumbnail: 'https://via.placeholder.com/150',
price: 29.99,
publishedAt: '2023-06-15 14:30',
status: '已发布',
mediaTypes: ['文章', '视频'],
viewCount: 1254
},
{
id: 2,
title: '前端开发最佳实践',
author: '李四',
thumbnail: 'https://via.placeholder.com/150',
price: 49.99,
publishedAt: '2023-06-10 09:15',
status: '草稿',
mediaTypes: ['文章'],
viewCount: 789
},
{
id: 3,
title: '数据分析入门指南',
author: '王五',
thumbnail: 'https://via.placeholder.com/150',
price: 0.00,
publishedAt: '2023-06-05 16:45',
status: '已下架',
mediaTypes: ['文章', '音频'],
viewCount: 2567
}
]);
const posts = ref([]);
// Pagination state
const page = ref(0); // 改为从0开始计数
const limit = ref(10);
const total = ref(0);
// Status mapping
const statusMap = {
1: '已发布',
2: '草稿',
3: '已下架'
};
// Transform assets to media types
const getMediaTypes = (assets) => {
return [...new Set(assets.map(asset => {
switch (asset.type) {
case 'audio': return '音频';
case 'video': return '视频';
default: return '文章';
}
}))];
};
// Navigate to post creation page
const navigateToCreatePost = () => {
@@ -122,17 +96,41 @@ const confirmDelete = (post) => {
});
};
// Fetch posts data
// Format datetime to YY/MM/DD HH:mm:ss
const formatDateTime = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(/\//g, '-');
};
// Calculate price after discount
const calculateDiscountPrice = (price, discount) => {
if (!discount) return price;
return price * (1 - discount / 100);
};
// Fetch posts data with pagination
const fetchPosts = async () => {
loading.value = true;
try {
// In a real app, this would be an API call
// const response = await postApi.getPosts();
// posts.value = response.data;
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
// Using sample data already defined above
const response = await postService.getPosts(page.value + 1, limit.value); // API调用时页码加1
posts.value = response.items.map(post => ({
...post,
status: statusMap[post.status] || '未知',
mediaTypes: getMediaTypes(post.assets),
price: post.price / 100, // Convert cents to yuan
publishedAt: formatDateTime(post.created_at),
viewCount: post.views,
likes: post.likes
}));
total.value = response.total;
} catch (error) {
toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 });
} finally {
@@ -140,6 +138,13 @@ const fetchPosts = async () => {
}
};
// Handle page change
const onPage = (event) => {
page.value = event.page; // PrimeVue的页码从0开始
limit.value = event.rows;
fetchPosts();
};
onMounted(() => {
fetchPosts();
});
@@ -183,11 +188,12 @@ const formatMediaTypes = (mediaTypes) => {
<InputText v-model="globalFilterValue" placeholder="搜索文章..." class="flex-1" />
</div>
<DataTable v-model:filters="filters" :value="posts" :paginator="true" :rows="5"
<DataTable v-model:filters="filters" :value="posts" :paginator="true" :rows="limit" :totalRecords="total"
:loading="loading" :lazy="true" v-model:first="page" @page="onPage"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[5, 10, 25]"
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" :loading="loading" dataKey="id"
:globalFilterFields="['title', 'author', 'status', 'mediaTypes']"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" dataKey="id"
:globalFilterFields="['title', 'description', 'status']"
:filters="{ global: { value: globalFilterValue, matchMode: 'contains' } }" stripedRows removableSort
class="p-datatable-sm" responsiveLayout="scroll">
@@ -212,11 +218,25 @@ const formatMediaTypes = (mediaTypes) => {
<Column field="price" header="价格" sortable>
<template #body="{ data }">
<div class="text-sm text-gray-900">{{ formatPrice(data.price) }}</div>
<div class="text-sm text-gray-900">
<span class="line-through text-gray-500" v-if="data.discount">
{{ formatPrice(data.price) }}
</span>
<span :class="{ 'ml-2': data.discount }">
{{ formatPrice(calculateDiscountPrice(data.price, data.discount)) }}
</span>
<span v-if="data.discount" class="ml-2 text-red-500">
(-{{ data.discount }}%)
</span>
</div>
</template>
</Column>
<Column field="publishedAt" header="发布时间" sortable></Column>
<Column field="publishedAt" header="发布时间" sortable>
<template #body="{ data }">
<div class="text-sm text-gray-900">{{ data.publishedAt }}</div>
</template>
</Column>
<Column field="status" header="发布状态" sortable>
<template #body="{ data }">
@@ -246,6 +266,12 @@ const formatMediaTypes = (mediaTypes) => {
</template>
</Column>
<Column field="likes" header="点赞数" sortable>
<template #body="{ data }">
<div class="text-sm text-gray-500">{{ data.likes }}</div>
</template>
</Column>
<Column header="操作" :exportable="false" style="min-width:8rem">
<template #body="{ data }">
<div class="flex justify-center space-x-2">