feat: update page list show

This commit is contained in:
yanghao05
2025-04-08 15:35:58 +08:00
parent 7d28dff65b
commit cf8bb5f192
13 changed files with 280 additions and 102 deletions

View File

@@ -64,8 +64,8 @@ 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.Limit limit := pagination.GetLimit()
offset := pagination.Offset() offset := pagination.GetOffset()
tbl := table.Medias tbl := table.Medias
stmt := tbl. stmt := tbl.
@@ -89,8 +89,8 @@ func (m *mediasModel) List(ctx context.Context, pagination *requests.Pagination)
} }
// Convert model.Medias to MediaItem // Convert model.Medias to MediaItem
mediaItems := lo.Map(medias, func(media model.Medias, _ int) MediaItem { mediaItems := lo.Map(medias, func(media model.Medias, _ int) *MediaItem {
return MediaItem{ return &MediaItem{
ID: media.ID, ID: media.ID,
Name: media.Name, Name: media.Name,
UploadTime: media.CreatedAt.Format("2006-01-02 15:04:05"), UploadTime: media.CreatedAt.Format("2006-01-02 15:04:05"),
@@ -108,6 +108,19 @@ func (m *mediasModel) List(ctx context.Context, pagination *requests.Pagination)
}, nil }, nil
} }
func (m *mediasModel) BatchCreate(ctx context.Context, models []*model.Medias) error {
stmt := table.Medias.INSERT(table.Medias.MutableColumns).MODELS(models)
m.log.Infof("sql: %s", stmt.DebugSql())
if _, err := stmt.ExecContext(ctx, db); err != nil {
m.log.Errorf("error creating media item: %v", err)
return err
}
m.log.Infof("media item created successfully")
return nil
}
func (m *mediasModel) Create(ctx context.Context, model *model.Medias) error { func (m *mediasModel) Create(ctx context.Context, model *model.Medias) error {
stmt := table.Medias.INSERT(table.Medias.MutableColumns).MODEL(model) stmt := table.Medias.INSERT(table.Medias.MutableColumns).MODEL(model)
m.log.Infof("sql: %s", stmt.DebugSql()) m.log.Infof("sql: %s", stmt.DebugSql())
@@ -123,9 +136,7 @@ func (m *mediasModel) Create(ctx context.Context, model *model.Medias) error {
func (m *mediasModel) ConvertFileTypeByMimeType(mimeType string) MediaType { func (m *mediasModel) ConvertFileTypeByMimeType(mimeType string) MediaType {
switch mimeType { switch mimeType {
case "image/jpeg": case "image/jpeg", "image/jpg", "image/png":
case "image/jpg":
case "image/png":
return MediaTypeImage return MediaTypeImage
case "video/mp4": case "video/mp4":
return MediaTypeVideo return MediaTypeVideo
@@ -133,19 +144,19 @@ func (m *mediasModel) ConvertFileTypeByMimeType(mimeType string) MediaType {
return MediaTypeAudio return MediaTypeAudio
case "application/pdf": case "application/pdf":
return MediaTypePDF return MediaTypePDF
case "application/msword": case "application/msword",
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
case "application/vnd.ms-excel": "application/vnd.ms-excel",
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
case "application/vnd.ms-powerpoint": "application/vnd.ms-powerpoint",
case "application/vnd.openxmlformats-officedocument.presentationml.presentation": "application/vnd.openxmlformats-officedocument.presentationml.presentation":
return MediaTypeDocument return MediaTypeDocument
case "application/rar": case "application/rar",
case "application/x-rar-compressed": "application/x-rar-compressed",
case "application/x-zip-compressed": "application/x-zip-compressed",
case "application/x-zip": "application/x-zip",
case "application/zip": "application/zip",
case "application/x-7z-compressed": "application/x-7z-compressed":
return MediaTypeArchive return MediaTypeArchive
} }
return MediaTypeUnknown return MediaTypeUnknown

View File

@@ -3,6 +3,8 @@ package models
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"math/rand"
"testing" "testing"
"time" "time"
@@ -54,6 +56,60 @@ func (s *MediasTestSuite) Test_countByCondition() {
}) })
} }
func (s *MediasTestSuite) Test_BatchCreate() {
Convey("Create", s.T(), func() {
Convey("valid media", func() {
database.Truncate(context.Background(), db, table.Medias.TableName())
models := []*model.Medias{
{
Name: "test 01",
CreatedAt: time.Now(),
MimeType: "video/mp4",
Size: rand.Int63n(math.MaxInt64), // Random size
Path: "path/to/media.mp4",
},
{
Name: "test 02",
CreatedAt: time.Now(),
MimeType: "audio/mp3",
Size: rand.Int63n(math.MaxInt64), // Random size
Path: "path/to/media.mp3",
},
{
Name: "test 03",
CreatedAt: time.Now(),
MimeType: "application/pdf",
Size: rand.Int63n(math.MaxInt64), // Random size
Path: "path/to/media.pdf",
},
{
Name: "test 04",
CreatedAt: time.Now(),
MimeType: "image/jpeg",
Size: rand.Int63n(math.MaxInt64), // Random size
Path: "path/to/media.jpeg",
},
{
Name: "test 05",
CreatedAt: time.Now(),
MimeType: "application/zip",
Size: rand.Int63n(math.MaxInt64), // Random size
Path: "path/to/media.zip",
},
}
count := 10
for i := 0; i < count; i++ {
err := Medias.BatchCreate(context.Background(), models)
Convey("Create should not return an error: "+fmt.Sprintf("%d", i), func() {
So(err, ShouldBeNil)
})
}
})
})
}
func (s *MediasTestSuite) Test_Create() { func (s *MediasTestSuite) Test_Create() {
Convey("Create", s.T(), func() { Convey("Create", s.T(), func() {
Convey("valid media", func() { Convey("valid media", func() {
@@ -132,3 +188,26 @@ func (s *MediasTestSuite) Test_Page() {
}) })
}) })
} }
// Test ConvertFileTypeByMimeType
func (s *MediasTestSuite) Test_ConvertFileTypeByMimeType() {
Convey("ConvertFileTypeByMimeType", s.T(), func() {
Convey("image", func() {
mimeType := "image/jpeg"
fileType := Medias.ConvertFileTypeByMimeType(mimeType)
So(fileType, ShouldEqual, MediaTypeImage)
})
Convey("video", func() {
mimeType := "video/mp4"
fileType := Medias.ConvertFileTypeByMimeType(mimeType)
So(fileType, ShouldEqual, MediaTypeVideo)
})
Convey("invalid mime type", func() {
mimeType := "invalid/type"
fileType := Medias.ConvertFileTypeByMimeType(mimeType)
So(fileType, ShouldEqual, MediaTypeUnknown)
})
})
}

View File

@@ -118,8 +118,8 @@ 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.Limit limit := pagination.GetLimit()
offset := pagination.Offset() offset := pagination.GetOffset()
tbl := table.Posts tbl := table.Posts
stmt := tbl. stmt := tbl.

View File

@@ -13,10 +13,17 @@ type Pagination struct {
Limit int64 `json:"limit" form:"limit" query:"limit"` Limit int64 `json:"limit" form:"limit" query:"limit"`
} }
func (filter *Pagination) Offset() int64 { func (filter *Pagination) GetOffset() int64 {
return (filter.Page - 1) * filter.Limit 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() *Pagination {
if filter.Page <= 0 { if filter.Page <= 0 {
filter.Page = 1 filter.Page = 1

View File

@@ -6,6 +6,7 @@ import (
"quyun/app/errorx" "quyun/app/errorx"
appHttp "quyun/app/http" appHttp "quyun/app/http"
"quyun/app/jobs" "quyun/app/jobs"
"quyun/app/models"
"quyun/app/service" "quyun/app/service"
_ "quyun/docs" _ "quyun/docs"
"quyun/providers/ali" "quyun/providers/ali"
@@ -47,6 +48,7 @@ func Command() atom.Option {
defaultProviders(). defaultProviders().
With( With(
jobs.Provide, jobs.Provide,
models.Provide,
). ).
WithProviders( WithProviders(
appHttp.Providers(), appHttp.Providers(),
@@ -68,7 +70,7 @@ type Service struct {
func Serve(cmd *cobra.Command, args []string) error { func Serve(cmd *cobra.Command, args []string) error {
return container.Container.Invoke(func(ctx context.Context, svc Service) error { return container.Container.Invoke(func(ctx context.Context, svc Service) error {
log.SetFormatter(&log.JSONFormatter{}) log.SetFormatter(&log.TextFormatter{})
if svc.App.Mode == app.AppModeDevelopment { if svc.App.Mode == app.AppModeDevelopment {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)

View File

@@ -32,3 +32,7 @@ Content-Type: application/json
### get oss token ### get oss token
GET {{host}}/v1/admin/uploads/token HTTP/1.1 GET {{host}}/v1/admin/uploads/token HTTP/1.1
Content-Type: application/json Content-Type: application/json
### get medias
GET {{host}}/v1/admin/medias HTTP/1.1
Content-Type: application/json

View File

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>趣云管理后台</title> <title>Hello</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon"> <link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="description" content="趣云内容管理系统后台"> <meta name="description" content="趣云内容管理系统后台">
</head> </head>

View File

@@ -37,7 +37,7 @@ const navItems = ref([
<Menubar :model="navItems" class="!rounded-none"> <Menubar :model="navItems" class="!rounded-none">
<template #start> <template #start>
<div class="flex items-center pr-4"> <div class="flex items-center pr-4">
<h2 class="m-0 text-2xl text-primary font-semibold">Admin Panel</h2> <h2 class="m-0 text-2xl text-primary font-semibold">Panel</h2>
</div> </div>
</template> </template>
<template #end> <template #end>

View File

@@ -0,0 +1,52 @@
{
"page": 0,
"limit": 0,
"total": 5,
"items": [
{
"id": 5,
"name": "test 05",
"upload_time": "2025-04-07 16:44:26",
"file_size": 100,
"media_type": "application/zip",
"file_type": "archive",
"thumbnail_url": ""
},
{
"id": 4,
"name": "test 04",
"upload_time": "2025-04-07 16:44:26",
"file_size": 100,
"media_type": "image/jpeg",
"file_type": "image",
"thumbnail_url": ""
},
{
"id": 3,
"name": "test 03",
"upload_time": "2025-04-07 16:44:26",
"file_size": 100,
"media_type": "application/pdf",
"file_type": "pdf",
"thumbnail_url": ""
},
{
"id": 2,
"name": "test 02",
"upload_time": "2025-04-07 16:44:26",
"file_size": 100,
"media_type": "audio/mp3",
"file_type": "unknown",
"thumbnail_url": ""
},
{
"id": 1,
"name": "test 01",
"upload_time": "2025-04-07 16:44:26",
"file_size": 100,
"media_type": "video/mp4",
"file_type": "video",
"thumbnail_url": ""
}
]
}

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
// Create axios instance with default config // Create axios instance with default config
const httpClient = axios.create({ const httpClient = axios.create({
baseURL: '/api', baseURL: '/v1',
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -0,0 +1,9 @@
import httpClient from './httpClient';
export const mediaService = {
getMedias({ page = 1, limit = 10 } = {}) {
return httpClient.get('/admin/medias', {
params: { page, limit }
});
},
};

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { mediaService } from '@/api/mediaService';
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';
@@ -12,7 +13,7 @@ import ProgressSpinner from 'primevue/progressspinner';
import Toast from 'primevue/toast'; import Toast from 'primevue/toast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
// Import useConfirm dynamically to avoid the error when the service is not provided // Import useConfirm dynamically to avoid the error when the service is not provided
const confirm = useConfirm(); const confirm = useConfirm();
@@ -44,55 +45,23 @@ const mediaTypes = ref([
const selectedMediaType = ref(mediaTypes.value[0]); const selectedMediaType = ref(mediaTypes.value[0]);
const globalFilterValue = ref(''); const globalFilterValue = ref('');
const loading = ref(false); const loading = ref(false);
const filters = ref({
global: { value: null, matchMode: 'contains' }
});
// Add pagination state
const first = ref(0);
const rows = ref(10);
const totalRecords = ref(0);
const currentPage = ref(1);
// Add computed for total pages
const totalPages = computed(() => {
return Math.ceil(totalRecords.value / rows.value);
});
// 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 mediaFiles = ref([ const mediaFiles = ref([]);
{
id: 1,
fileName: 'sunset-beach.jpg',
uploadTime: 'Today, 10:30 AM',
fileSize: '2.4 MB',
fileType: 'Image',
thumbnailUrl: 'https://via.placeholder.com/300x225',
iconClass: 'pi-image'
},
{
id: 2,
fileName: 'presentation.pdf',
uploadTime: 'Yesterday, 3:45 PM',
fileSize: '4.8 MB',
fileType: 'PDF',
thumbnailUrl: null,
iconClass: 'pi-file-pdf'
},
{
id: 3,
fileName: 'promo_video.mp4',
uploadTime: 'Aug 28, 2023',
fileSize: '24.8 MB',
fileType: 'Video',
thumbnailUrl: null,
iconClass: 'pi-video'
},
{
id: 4,
fileName: 'report_q3.docx',
uploadTime: 'Aug 25, 2023',
fileSize: '1.2 MB',
fileType: 'Document',
thumbnailUrl: null,
iconClass: 'pi-file'
},
{
id: 5,
fileName: 'podcast_interview.mp3',
uploadTime: 'Aug 20, 2023',
fileSize: '18.5 MB',
fileType: 'Audio',
thumbnailUrl: null,
iconClass: 'pi-volume-up'
}
]);
// File upload handling // File upload handling
const onUpload = (event) => { const onUpload = (event) => {
@@ -109,20 +78,18 @@ const onUpload = (event) => {
// Preview file // Preview file
const previewFile = (file) => { const previewFile = (file) => {
// In a real app, this would open a modal with a preview or navigate to a preview page toast.add({ severity: 'info', summary: 'Preview', detail: `Previewing ${file.name}`, life: 3000 });
toast.add({ severity: 'info', summary: 'Preview', detail: `Previewing ${file.fileName}`, life: 3000 });
}; };
// Download file // Download file
const downloadFile = (file) => { const downloadFile = (file) => {
// In a real app, this would trigger a download toast.add({ severity: 'info', summary: 'Download', detail: `Downloading ${file.name}`, life: 3000 });
toast.add({ severity: 'info', summary: 'Download', detail: `Downloading ${file.fileName}`, life: 3000 });
}; };
// Delete file // Delete file
const confirmDelete = (file) => { const confirmDelete = (file) => {
confirm.require({ confirm.require({
message: `Are you sure you want to delete ${file.fileName}?`, message: `Are you sure you want to delete ${file.name}?`,
header: 'Confirmation', header: 'Confirmation',
icon: 'pi pi-exclamation-triangle', icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger', acceptClass: 'p-button-danger',
@@ -138,13 +105,13 @@ const confirmDelete = (file) => {
const fetchMediaFiles = async () => { const fetchMediaFiles = async () => {
loading.value = true; loading.value = true;
try { try {
// In a real app, this would be an API call const response = await mediaService.getMedias({
// const response = await mediaApi.getMediaFiles(); page: currentPage.value,
// mediaFiles.value = response.data; limit: rows.value
});
// Simulate API delay mediaFiles.value = response.items;
await new Promise(resolve => setTimeout(resolve, 500)); totalRecords.value = response.total;
// Using sample data already defined above console.log(totalRecords.value);
} catch (error) { } catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load media files', life: 3000 }); toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load media files', life: 3000 });
} finally { } finally {
@@ -152,6 +119,16 @@ const fetchMediaFiles = async () => {
} }
}; };
// Add page change handler
const onPage = (event) => {
first.value = event.first;
rows.value = event.rows;
currentPage.value = Math.floor(event.first / event.rows) + 1;
console.log(event)
fetchMediaFiles();
};
onMounted(() => { onMounted(() => {
fetchMediaFiles(); fetchMediaFiles();
}); });
@@ -173,6 +150,27 @@ const getFileIcon = (file) => {
if (file.thumbnailUrl) return null; if (file.thumbnailUrl) return null;
return `pi ${file.iconClass}`; return `pi ${file.iconClass}`;
}; };
// Add helper function to format file size
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = BigInt(1024);
const sizes = ['B', 'KB', 'MB', 'GB'];
// Convert input to BigInt
const bytesValue = BigInt(bytes);
// Calculate the appropriate unit
let i = 0;
let size = bytesValue;
while (size >= k && i < sizes.length - 1) {
size = size / k;
i++;
}
// Convert back to number and format with commas
return `${Number(size).toLocaleString('en-US')} ${sizes[i]}`;
};
</script> </script>
<template> <template>
@@ -193,13 +191,22 @@ const getFileIcon = (file) => {
<InputText v-model="globalFilterValue" placeholder="Search media..." class="flex-1" /> <InputText v-model="globalFilterValue" placeholder="Search media..." class="flex-1" />
</div> </div>
<DataTable v-model:filters="filters" :value="mediaFiles" :paginator="true" :rows="5" <DataTable v-model:filters="filters" :value="mediaFiles" :paginator="true" v-model:first="first"
v-model:rows="rows" :totalRecords="totalRecords" @page="onPage"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown" paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[5, 10, 25]" :rows-per-page-options="[10, 25, 50]"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries" :loading="loading" current-page-report-template="Showing {first} to {last} of {totalRecords} entries" :lazy="true"
dataKey="id" :globalFilterFields="['fileName', 'fileType']" :show-current-page-report="true">
:filters="{ global: { value: globalFilterValue, matchMode: 'contains' } }" stripedRows removableSort <template #paginatorLeft>
class="p-datatable-sm" responsiveLayout="scroll"> <div class="flex items-center">
Per Page: {{ rows }}
</div>
</template>
<template #paginatorRight>
<div class="flex items-center">
Page {{ currentPage }} of {{ totalPages }}
</div>
</template>
<template #empty> <template #empty>
<div class="text-center p-4">No media files found.</div> <div class="text-center p-4">No media files found.</div>
</template> </template>
@@ -210,28 +217,32 @@ const getFileIcon = (file) => {
</div> </div>
</template> </template>
<Column field="fileName" header="File Name" sortable> <Column field="name" header="File Name" sortable>
<template #body="{ data }"> <template #body="{ data }">
<div class="flex items-center"> <div class="flex items-center">
<div v-if="data.thumbnailUrl" class="flex-shrink-0 h-10 w-10 mr-3"> <div v-if="data.thumbnail_url" class="flex-shrink-0 h-10 w-10 mr-3">
<img class="h-10 w-10 object-cover rounded" :src="data.thumbnailUrl" <img class="h-10 w-10 object-cover rounded" :src="data.thumbnail_url" :alt="data.name">
:alt="data.fileName">
</div> </div>
<div v-else <div v-else
class="flex-shrink-0 h-10 w-10 mr-3 bg-gray-100 rounded flex items-center justify-center"> class="flex-shrink-0 h-10 w-10 mr-3 bg-gray-100 rounded flex items-center justify-center">
<i :class="getFileIcon(data)" class="text-2xl"></i> <i :class="getFileIcon(data)" class="text-2xl"></i>
</div> </div>
<div class="text-sm font-medium text-gray-900">{{ data.fileName }}</div> <div class="text-sm font-medium text-gray-900">{{ data.name }}</div>
</div> </div>
</template> </template>
</Column> </Column>
<Column field="uploadTime" header="Upload Time" sortable></Column> <Column field="upload_time" header="Upload Time" sortable></Column>
<Column field="fileSize" header="File Size" sortable></Column>
<Column field="fileType" header="File Type" sortable> <Column field="file_size" header="File Size" sortable>
<template #body="{ data }"> <template #body="{ data }">
<Badge :value="data.fileType" :severity="getBadgeSeverity(data.fileType)" /> {{ formatFileSize(data.file_size) }}
</template>
</Column>
<Column field="media_type" header="File Type" sortable>
<template #body="{ data }">
<Badge :value="data.file_type" :severity="getBadgeSeverity(data.file_type)" />
</template> </template>
<template #filter="{ filterModel, filterCallback }"> <template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="mediaTypes" <Dropdown v-model="filterModel.value" @change="filterCallback()" :options="mediaTypes"

View File

@@ -15,6 +15,9 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
open: true open: true,
proxy: {
'/v1': 'http://localhost:8088',
}
} }
}); });