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) { func (m *mediasModel) List(ctx context.Context, pagination *requests.Pagination) (*requests.Pager, error) {
limit := pagination.GetLimit() pagination.Format()
offset := pagination.GetOffset()
tbl := table.Medias tbl := table.Medias
stmt := tbl. stmt := tbl.
SELECT(tbl.AllColumns). SELECT(tbl.AllColumns).
ORDER_BY(tbl.ID.DESC()). ORDER_BY(tbl.ID.DESC()).
LIMIT(limit). LIMIT(pagination.Limit).
OFFSET(offset) OFFSET(pagination.Offset)
m.log.Infof("sql: %s", stmt.DebugSql()) m.log.Infof("sql: %s", stmt.DebugSql())
var medias []model.Medias var medias []model.Medias

View File

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

View File

@@ -2,10 +2,14 @@ package models
import ( import (
"context" "context"
"fmt"
"math/rand"
"testing" "testing"
"quyun/app/service/testx" "quyun/app/service/testx"
"quyun/database" "quyun/database"
"quyun/database/fields"
"quyun/database/schemas/public/model"
"quyun/database/schemas/public/table" "quyun/database/schemas/public/table"
. "github.com/smartystreets/goconvey/convey" . "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() { Convey("Test_Demo", s.T(), func() {
database.Truncate(context.Background(), db, table.Posts.TableName()) 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 { type Pagination struct {
Page int64 `json:"page" form:"page" query:"page"` Page int64 `json:"page" form:"page" query:"page"`
Limit int64 `json:"limit" form:"limit" query:"limit"` Limit int64 `json:"limit" form:"limit" query:"limit"`
Offset int64 `json:"-"`
} }
func (filter *Pagination) GetOffset() int64 { func (filter *Pagination) Format() {
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 {
if filter.Page <= 0 { if filter.Page <= 0 {
filter.Page = 1 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 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 medias
GET {{host}}/v1/admin/medias HTTP/1.1 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 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> <script setup>
import { postService } from '@/api/postService'; // Assuming you have a postService for API calls
import { InputText } from 'primevue'; import { InputText } from 'primevue';
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 Column from 'primevue/column';
import ConfirmDialog from 'primevue/confirmdialog'; import ConfirmDialog from 'primevue/confirmdialog';
@@ -18,22 +20,6 @@ const router = useRouter();
const confirm = useConfirm(); const confirm = useConfirm();
const toast = useToast(); 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 // Post statuses for filtering
const statusOptions = ref([ const statusOptions = ref([
{ name: '所有状态', value: null }, { name: '所有状态', value: null },
@@ -50,46 +36,34 @@ const mediaTypeOptions = ref([
{ name: '音频', value: '音频' } { name: '音频', value: '音频' }
]); ]);
const selectedStatus = ref(statusOptions.value[0]);
const globalFilterValue = ref(''); const globalFilterValue = ref('');
const loading = ref(false); const loading = ref(false);
// Sample data - in a real app, this would come from an API // Sample data - in a real app, this would come from an API
const posts = ref([ const posts = ref([]);
{
id: 1, // Pagination state
title: '如何高效学习编程', const page = ref(0); // 改为从0开始计数
author: '张三', const limit = ref(10);
thumbnail: 'https://via.placeholder.com/150', const total = ref(0);
price: 29.99,
publishedAt: '2023-06-15 14:30', // Status mapping
status: '已发布', const statusMap = {
mediaTypes: ['文章', '视频'], 1: '已发布',
viewCount: 1254 2: '草稿',
}, 3: '已下架'
{ };
id: 2,
title: '前端开发最佳实践', // Transform assets to media types
author: '李四', const getMediaTypes = (assets) => {
thumbnail: 'https://via.placeholder.com/150', return [...new Set(assets.map(asset => {
price: 49.99, switch (asset.type) {
publishedAt: '2023-06-10 09:15', case 'audio': return '音频';
status: '草稿', case 'video': return '视频';
mediaTypes: ['文章'], default: return '文章';
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
}
]);
// Navigate to post creation page // Navigate to post creation page
const navigateToCreatePost = () => { 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 () => { const fetchPosts = async () => {
loading.value = true; loading.value = true;
try { try {
// In a real app, this would be an API call const response = await postService.getPosts(page.value + 1, limit.value); // API调用时页码加1
// const response = await postApi.getPosts(); posts.value = response.items.map(post => ({
// posts.value = response.data; ...post,
status: statusMap[post.status] || '未知',
// Simulate API delay mediaTypes: getMediaTypes(post.assets),
await new Promise(resolve => setTimeout(resolve, 500)); price: post.price / 100, // Convert cents to yuan
// Using sample data already defined above publishedAt: formatDateTime(post.created_at),
viewCount: post.views,
likes: post.likes
}));
total.value = response.total;
} catch (error) { } catch (error) {
toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 }); toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 });
} finally { } 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(() => { onMounted(() => {
fetchPosts(); fetchPosts();
}); });
@@ -183,11 +188,12 @@ const formatMediaTypes = (mediaTypes) => {
<InputText v-model="globalFilterValue" placeholder="搜索文章..." class="flex-1" /> <InputText v-model="globalFilterValue" placeholder="搜索文章..." class="flex-1" />
</div> </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" paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[5, 10, 25]" :rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" :loading="loading" dataKey="id" currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" dataKey="id"
:globalFilterFields="['title', 'author', 'status', 'mediaTypes']" :globalFilterFields="['title', 'description', 'status']"
:filters="{ global: { value: globalFilterValue, matchMode: 'contains' } }" stripedRows removableSort :filters="{ global: { value: globalFilterValue, matchMode: 'contains' } }" stripedRows removableSort
class="p-datatable-sm" responsiveLayout="scroll"> class="p-datatable-sm" responsiveLayout="scroll">
@@ -212,11 +218,25 @@ const formatMediaTypes = (mediaTypes) => {
<Column field="price" header="价格" sortable> <Column field="price" header="价格" sortable>
<template #body="{ data }"> <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> </template>
</Column> </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> <Column field="status" header="发布状态" sortable>
<template #body="{ data }"> <template #body="{ data }">
@@ -246,6 +266,12 @@ const formatMediaTypes = (mediaTypes) => {
</template> </template>
</Column> </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"> <Column header="操作" :exportable="false" style="min-width:8rem">
<template #body="{ data }"> <template #body="{ data }">
<div class="flex justify-center space-x-2"> <div class="flex justify-center space-x-2">