feat: update page
This commit is contained in:
@@ -78,3 +78,57 @@ func (ctl *posts) Create(ctx fiber.Ctx, form *PostForm) error {
|
||||
func (ctl *posts) Update(ctx fiber.Ctx, id int64, form *model.Posts) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete posts
|
||||
// @Router /v1/admin/posts/:id [delete]
|
||||
// @Bind id path
|
||||
func (ctl *posts) Delete(ctx fiber.Ctx, id int64) error {
|
||||
post, err := models.Posts.GetByID(ctx.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if post == nil {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
if err := models.Posts.DeleteByID(ctx.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PostItem struct {
|
||||
*model.Posts
|
||||
Medias []*models.MediaItem `json:"medias"`
|
||||
}
|
||||
|
||||
// Show posts by id
|
||||
// @Router /v1/admin/posts/:id [get]
|
||||
// @Bind id path
|
||||
func (ctl *posts) Show(ctx fiber.Ctx, id int64) (*PostItem, error) {
|
||||
post, err := models.Posts.GetByID(ctx.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
medias, err := models.Medias.GetByIds(ctx.Context(), lo.Map(post.Assets.Data, func(asset fields.MediaAsset, _ int) int64 {
|
||||
return asset.Media
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PostItem{
|
||||
Posts: post,
|
||||
Medias: lo.Map(medias, func(media *model.Medias, _ int) *models.MediaItem {
|
||||
return &models.MediaItem{
|
||||
ID: media.ID,
|
||||
Name: media.Name,
|
||||
UploadTime: media.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
FileSize: media.Size,
|
||||
MimeType: media.MimeType,
|
||||
FileType: models.Medias.ConvertFileTypeByMimeType(media.MimeType),
|
||||
ThumbnailUrl: "",
|
||||
}
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -56,6 +56,16 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Body[model.Posts]("form"),
|
||||
))
|
||||
|
||||
router.Delete("/v1/admin/posts/:id", Func1(
|
||||
r.posts.Delete,
|
||||
PathParam[int64]("id"),
|
||||
))
|
||||
|
||||
router.Get("/v1/admin/posts/:id", DataFunc1(
|
||||
r.posts.Show,
|
||||
PathParam[int64]("id"),
|
||||
))
|
||||
|
||||
// 注册路由组: uploads
|
||||
router.Post("/v1/admin/uploads/:md5/chunks/:idx", Func3(
|
||||
r.uploads.Chunks,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"quyun/app/requests"
|
||||
"quyun/database/fields"
|
||||
"quyun/database/schemas/public/model"
|
||||
"quyun/database/schemas/public/table"
|
||||
|
||||
@@ -29,9 +28,7 @@ func (m *postsModel) Prepare() error {
|
||||
func (m *postsModel) BuildConditionWithKey(key *string) BoolExpression {
|
||||
tbl := table.Posts
|
||||
|
||||
cond := tbl.DeletedAt.IS_NULL().AND(
|
||||
tbl.Status.EQ(Int32(int32(fields.PostStatusPublished))),
|
||||
)
|
||||
cond := tbl.DeletedAt.IS_NULL()
|
||||
|
||||
if key == nil || *key == "" {
|
||||
return cond
|
||||
@@ -58,8 +55,6 @@ func (m *postsModel) GetByID(ctx context.Context, id int64) (*model.Posts, error
|
||||
WHERE(
|
||||
tbl.ID.EQ(Int64(id)).AND(
|
||||
tbl.DeletedAt.IS_NULL(),
|
||||
).AND(
|
||||
tbl.Status.EQ(Int32(int32(fields.PostStatusPublished))),
|
||||
),
|
||||
)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
@@ -211,3 +206,21 @@ func (m *postsModel) Buy(ctx context.Context, userId, postId int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID soft delete item
|
||||
func (m *postsModel) DeleteByID(ctx context.Context, id int64) error {
|
||||
tbl := table.Posts
|
||||
stmt := tbl.
|
||||
UPDATE(tbl.DeletedAt).
|
||||
SET(TimestampT(time.Now())).
|
||||
WHERE(
|
||||
tbl.ID.EQ(Int64(id)),
|
||||
)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
if _, err := stmt.ExecContext(ctx, db); err != nil {
|
||||
m.log.Errorf("error deleting post: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -43,4 +43,7 @@ Content-Type: application/json
|
||||
|
||||
### get posts with keyword
|
||||
GET {{host}}/v1/admin/posts?page=1&limit=10&keyword=99123 HTTP/1.1
|
||||
Content-Type: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
### delete posts
|
||||
DELETE {{host}}/v1/admin/posts/103 HTTP/1.1
|
||||
25
frontend/admin/src/api/posts_item.json
Normal file
25
frontend/admin/src/api/posts_item.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": 102,
|
||||
"created_at": "2025-04-09T20:25:00.118963Z",
|
||||
"updated_at": "2025-04-09T20:25:00.118963Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"title": "adsfafd",
|
||||
"description": "afda",
|
||||
"content": "",
|
||||
"price": 123123,
|
||||
"discount": 100,
|
||||
"views": 0,
|
||||
"likes": 0,
|
||||
"tags": null,
|
||||
"assets": [
|
||||
{
|
||||
"type": "unknown",
|
||||
"media": 47
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"media": 48
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { postService } from '@/api/postService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
@@ -9,7 +10,6 @@ import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
@@ -26,23 +26,25 @@ const post = reactive({
|
||||
id: null,
|
||||
title: '',
|
||||
price: 0,
|
||||
discount: 100,
|
||||
introduction: '',
|
||||
status: '',
|
||||
selectedMedia: [],
|
||||
author: '',
|
||||
publishedAt: '',
|
||||
viewCount: 0,
|
||||
mediaTypes: [],
|
||||
medias: [],
|
||||
status: 0,
|
||||
});
|
||||
|
||||
// Status options
|
||||
const statusOptions = ref(['已发布', '草稿', '已下架']);
|
||||
const statusOptions = [
|
||||
{ label: '发布', value: 1 },
|
||||
{ label: '草稿', value: 0 }
|
||||
];
|
||||
|
||||
// Validation state
|
||||
const errors = reactive({
|
||||
title: '',
|
||||
introduction: '',
|
||||
selectedMedia: ''
|
||||
selectedMedia: '',
|
||||
discount: ''
|
||||
});
|
||||
|
||||
// Media selection dialog state
|
||||
@@ -99,65 +101,22 @@ const mediaItems = ref([
|
||||
const fetchPost = async (id) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// In a real app, you would call an API to get the post
|
||||
// const response = await postApi.getPostById(id);
|
||||
// Object.assign(post, response.data);
|
||||
|
||||
// For demo, we'll use some sample data
|
||||
const samplePosts = [
|
||||
{
|
||||
id: 1,
|
||||
title: '如何高效学习编程',
|
||||
author: '张三',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 29.99,
|
||||
publishedAt: '2023-06-15 14:30',
|
||||
status: '已发布',
|
||||
mediaTypes: ['文章', '视频'],
|
||||
viewCount: 1254,
|
||||
introduction: '这是一篇关于高效学习编程的文章,包含了多种学习方法和技巧。',
|
||||
selectedMedia: [mediaItems.value[0], mediaItems.value[2]]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '前端开发最佳实践',
|
||||
author: '李四',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 49.99,
|
||||
publishedAt: '2023-06-10 09:15',
|
||||
status: '草稿',
|
||||
mediaTypes: ['文章'],
|
||||
viewCount: 789,
|
||||
introduction: '探讨现代前端开发的各种最佳实践和设计模式。',
|
||||
selectedMedia: [mediaItems.value[1]]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '数据分析入门指南',
|
||||
author: '王五',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 0.00,
|
||||
publishedAt: '2023-06-05 16:45',
|
||||
status: '已下架',
|
||||
mediaTypes: ['文章', '音频'],
|
||||
viewCount: 2567,
|
||||
introduction: '介绍数据分析的基础知识和常用工具。',
|
||||
selectedMedia: [mediaItems.value[3], mediaItems.value[4]]
|
||||
}
|
||||
];
|
||||
|
||||
const foundPost = samplePosts.find(p => p.id === parseInt(id));
|
||||
if (foundPost) {
|
||||
Object.assign(post, foundPost);
|
||||
// Initialize selectedMediaItems with the post's media for the dialog
|
||||
selectedMediaItems.value = [...post.selectedMedia];
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '未找到该文章', life: 3000 });
|
||||
const response = await postService.getPost(id);
|
||||
if (response.status !== 200) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: response.message, life: 3000 });
|
||||
router.push('/posts');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const postData = response.data;
|
||||
post.id = postData.id;
|
||||
post.title = postData.title;
|
||||
post.price = postData.price;
|
||||
post.discount = postData.discount || 100;
|
||||
post.introduction = postData.introduction;
|
||||
post.status = postData.status;
|
||||
post.selectedMedia = postData.medias || [];
|
||||
post.medias = postData.medias?.map(media => media.id) || [];
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 });
|
||||
router.push('/posts');
|
||||
@@ -235,21 +194,26 @@ const savePost = async () => {
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (post.discount < 0 || post.discount > 100) {
|
||||
errors.discount = '折扣必须在0到100之间';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
toast.add({ severity: 'error', summary: '表单错误', detail: '请检查表单中的错误并修正', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// In a real app, you would call an API to update the post
|
||||
// await postApi.updatePost(post.id, post);
|
||||
post.medias = post.selectedMedia.map(media => media.id);
|
||||
const resp = await postService.updatePost(post.id, post);
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (resp.status !== 200) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: resp.message, life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '文章已成功更新', life: 3000 });
|
||||
|
||||
// Navigate back to the post list
|
||||
router.push('/posts');
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '更新文章失败', life: 3000 });
|
||||
@@ -264,11 +228,11 @@ const cancelEdit = () => {
|
||||
// File type badge severity mapping
|
||||
const getBadgeSeverity = (fileType) => {
|
||||
const map = {
|
||||
'Image': 'info',
|
||||
'PDF': 'danger',
|
||||
'Video': 'warning',
|
||||
'Document': 'primary',
|
||||
'Audio': 'success'
|
||||
'image': 'info',
|
||||
'pdf': 'danger',
|
||||
'video': 'warning',
|
||||
'document': 'primary',
|
||||
'audio': 'success'
|
||||
};
|
||||
return map[fileType] || 'info';
|
||||
};
|
||||
@@ -276,11 +240,11 @@ const getBadgeSeverity = (fileType) => {
|
||||
// File type icon mapping
|
||||
const getFileIcon = (file) => {
|
||||
const map = {
|
||||
'Image': 'pi-image',
|
||||
'PDF': 'pi-file-pdf',
|
||||
'Video': 'pi-video',
|
||||
'Document': 'pi-file',
|
||||
'Audio': 'pi-volume-up'
|
||||
'image': 'pi-image',
|
||||
'pdf': 'pi-file-pdf',
|
||||
'video': 'pi-video',
|
||||
'document': 'pi-file',
|
||||
'audio': 'pi-volume-up'
|
||||
};
|
||||
return `pi ${map[file.fileType] || 'pi-file'}`;
|
||||
};
|
||||
@@ -315,15 +279,14 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-0!">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Title -->
|
||||
<div class="col-span-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||||
<InputText id="title" v-model="post.title" class="w-full p-inputtext-lg" placeholder="输入文章标题" />
|
||||
<small v-if="errors.title" class="p-error">{{ errors.title }}</small>
|
||||
<small v-if="errors.title" class="text-red-500">{{ errors.title }}</small>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Price -->
|
||||
<div class="col-span-1">
|
||||
<label for="price" class="block text-sm font-medium text-gray-700 mb-1">价格 (¥)</label>
|
||||
@@ -331,10 +294,24 @@ onMounted(() => {
|
||||
class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<!-- Discount -->
|
||||
<div class="col-span-1">
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<Dropdown id="status" v-model="post.status" :options="statusOptions" class="w-full" />
|
||||
<label for="discount" class="block text-sm font-medium text-gray-700 mb-1">折扣 (%)</label>
|
||||
<InputNumber id="discount" v-model="post.discount" :min="0" :max="100" :minFractionDigits="0"
|
||||
:maxFractionDigits="0" class="w-full" placeholder="输入折扣百分比" />
|
||||
<small v-if="errors.discount" class="text-red-500">{{ errors.discount }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<div class="flex gap-4">
|
||||
<div v-for="option in statusOptions" :key="option.value" class="flex items-center">
|
||||
<RadioButton :value="option.value" v-model="post.status"
|
||||
:inputId="'status_' + option.value" />
|
||||
<label :for="'status_' + option.value" class="ml-2">{{ option.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Introduction -->
|
||||
@@ -365,16 +342,16 @@ onMounted(() => {
|
||||
class="relative border border-gray-200 rounded-md p-2 flex items-center">
|
||||
<div v-if="media.thumbnailUrl" class="flex-shrink-0 h-10 w-10 mr-3">
|
||||
<img class="h-10 w-10 object-cover rounded" :src="media.thumbnailUrl"
|
||||
:alt="media.fileName">
|
||||
:alt="media.name">
|
||||
</div>
|
||||
<div v-else
|
||||
class="flex-shrink-0 h-10 w-10 mr-3 bg-gray-100 rounded flex items-center justify-center">
|
||||
<i :class="getFileIcon(media)" class="text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ media.fileName }}
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ media.name }}
|
||||
</div>
|
||||
<Badge :value="media.fileType" :severity="getBadgeSeverity(media.fileType)"
|
||||
<Badge :value="media.file_type" :severity="getBadgeSeverity(media.file_type)"
|
||||
class="text-xs" />
|
||||
</div>
|
||||
<Button icon="pi pi-times" class="p-button-rounded p-button-text p-button-sm"
|
||||
|
||||
@@ -39,6 +39,11 @@ const mediaTypeOptions = ref([
|
||||
const globalFilterValue = ref('');
|
||||
const loading = ref(false);
|
||||
const searchTimeout = ref(null);
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
status: { value: null, matchMode: 'equals' },
|
||||
mediaTypes: { value: null, matchMode: 'equals' }
|
||||
});
|
||||
|
||||
// Sample data - in a real app, this would come from an API
|
||||
const posts = ref([]);
|
||||
@@ -50,9 +55,8 @@ const total = ref(0); // 总记录数
|
||||
|
||||
// Status mapping
|
||||
const statusMap = {
|
||||
1: '已发布',
|
||||
2: '草稿',
|
||||
3: '已下架'
|
||||
0: '发布',
|
||||
1: '草稿',
|
||||
};
|
||||
|
||||
// Transform assets to media types
|
||||
@@ -90,9 +94,22 @@ const confirmDelete = (post) => {
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
// In a real app, you would call an API to delete the post
|
||||
posts.value = posts.value.filter(p => p.id !== post.id);
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '文章已删除', life: 3000 });
|
||||
// // In a real app, you would call an API to delete the post
|
||||
// posts.value = posts.value.filter(p => p.id !== post.id);
|
||||
// toast.add({ severity: 'success', summary: '成功', detail: '文章已删除', life: 3000 });
|
||||
|
||||
// call remote delete
|
||||
postService.deletePost(post.id)
|
||||
.then(() => {
|
||||
// toast success
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '文章已删除', life: 3000 });
|
||||
fetchPosts();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Delete error:', error); // Debug log
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '删除文章失败', life: 3000 });
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -217,9 +234,8 @@ const formatMediaTypes = (mediaTypes) => {
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
: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">
|
||||
:globalFilterFields="['title', 'description', 'status']" :filters="filters.value" stripedRows
|
||||
removableSort class="p-datatable-sm" responsiveLayout="scroll">
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center p-4">未找到文章。</div>
|
||||
|
||||
Reference in New Issue
Block a user