From 4b1c0d9bff3bccca4560ed72ae649d05d1aca77e Mon Sep 17 00:00:00 2001 From: Alivin Date: Fri, 12 Jun 2026 14:37:09 +0800 Subject: [PATCH 1/2] feat: add newsletter page --- src/components/Header.tsx | 30 +- src/pages/api/newsletter.ts | 283 +++++++++++++++ src/pages/newsletter/[id]/edit.tsx | 330 +++++++++++++++++ src/pages/newsletter/[id]/index.tsx | 231 ++++++++++++ src/pages/newsletter/index.tsx | 538 +++++++++++++++++++++------- src/pages/newsletter/new.tsx | 292 +++++++++++++++ 6 files changed, 1561 insertions(+), 143 deletions(-) create mode 100644 src/pages/api/newsletter.ts create mode 100644 src/pages/newsletter/[id]/edit.tsx create mode 100644 src/pages/newsletter/[id]/index.tsx create mode 100644 src/pages/newsletter/new.tsx diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 46059ca0..f0a31bc9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -7,6 +7,12 @@ import Auth from './Auth'; import { useState, useEffect } from 'react'; // import { SiWechat, SiX } from 'react-icons/si'; +// 官方资源导航配置:桌面下拉和移动端抽屉共用,新增入口时只维护这里即可。 +const officialResourceLinks = [ + { key: 'blog', href: '/blogs', label: '博客', icon: '📝' }, + { key: 'newsletter', href: '/newsletter', label: '周报', icon: '📰' }, +]; + export default function Header() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -79,7 +85,7 @@ export default function Header() {
- + Monad 中文社区 Logo Monad 中文社区
@@ -138,9 +144,10 @@ export default function Header() { 博客 }, - ], + items: officialResourceLinks.map(({ key, href, label }) => ({ + key, + label: {label}, + })), }} placement="bottom" trigger={['hover']} @@ -264,10 +271,17 @@ export default function Header() {

官方资源

- setMobileMenuOpen(false)}> - 📝 - 博客 - + {officialResourceLinks.map(({ key, href, label, icon }) => ( + setMobileMenuOpen(false)} + > + {icon} + {label} + + ))}
diff --git a/src/pages/api/newsletter.ts b/src/pages/api/newsletter.ts new file mode 100644 index 00000000..01a864e3 --- /dev/null +++ b/src/pages/api/newsletter.ts @@ -0,0 +1,283 @@ +import { apiRequest } from './api'; + +export const NEWSLETTER_CATEGORY = 'newsletter'; + +export interface NewsletterParams { + title: string; + description: string; + content: string; + cover_img: string; + source_link: string; + source_type?: string; + tags: string[]; + author: string; + translator: string; +} + +export interface GetNewslettersParams { + keyword?: string; + tag?: string; + order?: 'asc' | 'desc'; + page?: number; + user_id?: number; + page_size?: number; + author?: string; + translator?: string; + publish_status?: number; + publish_time?: string; +} + +export interface NewsletterUser { + ID: number; + email: string; + username: string; + avatar: string; + github: string; +} + +export interface Newsletter { + ID: number; + title: string; + CreatedAt: string; + UpdatedAt: string; + description: string; + content: string; + source_link: string; + cover_img: string; + category: string; + author: string; + translator: string; + tags: string[]; + publish_status?: number; + publish_time?: string; + publisher?: NewsletterUser; + publisher_id?: number; + view_count?: number; +} + +interface RawPaginatedNewsletterData { + blogs?: Newsletter[]; + newsletters?: Newsletter[]; + page: number; + page_size: number; + total: number; +} + +export interface PaginatedNewsletterData { + newsletters: Newsletter[]; + page: number; + page_size: number; + total: number; +} + +export interface NewsletterListResult { + success: boolean; + message: string; + data?: PaginatedNewsletterData; +} + +export interface NewsletterResult { + success: boolean; + message: string; + data?: Newsletter; +} + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : '网络错误,请稍后重试'; + +// 周报复用后端内容表,通过 category 固定隔离为 newsletter。 +export const createNewsletter = async ( + params: NewsletterParams +): Promise => { + try { + const body = { + title: params.title.trim(), + desc: params.description.trim(), + content: params.content, + category: NEWSLETTER_CATEGORY, + source_link: params.source_link, + source_type: params.source_type ?? 'official', + cover_img: params.cover_img, + tags: params.tags ?? [], + author: params.author ?? '', + translator: params.translator ?? '', + }; + + const response = await apiRequest('/blogs', 'POST', body); + + if (response.code === 200 && response.data) { + return { + success: true, + message: response.message ?? '周报创建成功', + data: response.data as unknown as Newsletter, + }; + } + + return { success: false, message: response.message ?? '周报创建失败' }; + } catch (error: unknown) { + console.error('创建周报异常:', error); + return { success: false, message: getErrorMessage(error) }; + } +}; + +export const updateNewsletter = async ( + newsletterId: string, + params: NewsletterParams +): Promise => { + try { + const body = { + title: params.title.trim(), + desc: params.description.trim(), + content: params.content, + category: NEWSLETTER_CATEGORY, + source_link: params.source_link, + cover_img: params.cover_img, + tags: params.tags ?? [], + author: params.author ?? '', + translator: params.translator ?? '', + }; + + const response = await apiRequest( + `/blogs/${newsletterId}`, + 'PUT', + body + ); + + if (response.code === 200 && response.data) { + return { + success: true, + message: response.message ?? '周报更新成功', + data: response.data as unknown as Newsletter, + }; + } + + return { success: false, message: response.message ?? '周报更新失败' }; + } catch (error: unknown) { + console.error('更新周报异常:', error); + return { success: false, message: getErrorMessage(error) }; + } +}; + +export const getNewsletters = async ( + params: GetNewslettersParams = {} +): Promise => { + try { + const query = new URLSearchParams(); + + if (params.keyword?.trim()) query.append('keyword', params.keyword.trim()); + if (params.tag?.trim()) query.append('tag', params.tag.trim()); + if (params.publish_status != null) { + query.append('publish_status', params.publish_status.toString()); + } + if (params.user_id != null) query.append('user_id', params.user_id.toString()); + + query.append('category', NEWSLETTER_CATEGORY); + query.append('order', params.order ?? 'desc'); + query.append('page', (params.page ?? 1).toString()); + query.append('page_size', (params.page_size ?? 6).toString()); + + const response = await apiRequest( + `/blogs?${query.toString()}`, + 'GET' + ); + + if (response.code === 200 && response.data) { + const newsletters = response.data.newsletters ?? response.data.blogs ?? []; + + return { + success: true, + message: response.message ?? '获取周报列表成功', + data: { + newsletters, + page: response.data.page, + page_size: response.data.page_size, + total: response.data.total, + }, + }; + } + + return { success: false, message: response.message ?? '获取周报列表失败' }; + } catch (error: unknown) { + console.error('获取周报列表异常:', error); + return { success: false, message: getErrorMessage(error) }; + } +}; + +export const getNewsletterById = async ( + newsletterId: string +): Promise => { + try { + if (!newsletterId) { + return { success: false, message: '周报ID不能为空' }; + } + + const response = await apiRequest( + `/blogs/${newsletterId}`, + 'GET' + ); + + if (response.code === 200 && response.data) { + const newsletter = response.data as unknown as Newsletter; + if (newsletter.category !== NEWSLETTER_CATEGORY) { + return { success: false, message: '周报不存在' }; + } + + return { + success: true, + message: response.message ?? '获取周报成功', + data: newsletter, + }; + } + + return { success: false, message: response.message ?? '获取周报失败' }; + } catch (error: unknown) { + console.error('获取周报异常:', error); + return { success: false, message: getErrorMessage(error) }; + } +}; + +export const deleteNewsletter = async ( + newsletterId: number +): Promise => { + try { + const response = await apiRequest( + `/blogs/${newsletterId}`, + 'DELETE' + ); + + if (response.code === 200) { + return { success: true, message: response.message ?? '周报删除成功' }; + } + + return { success: false, message: response.message ?? '周报删除失败' }; + } catch (error: unknown) { + console.error('删除周报异常:', error); + return { success: false, message: getErrorMessage(error) }; + } +}; + +export const updateNewsletterPublishStatus = async ( + newsletterId: string, + publishStatus: number +): Promise => { + try { + const response = await apiRequest( + `/blogs/${newsletterId}/status`, + 'PUT', + { publish_status: publishStatus } + ); + + if (response.code === 200 && response.data) { + return { + success: true, + message: response.message ?? '周报状态更新成功', + data: response.data as unknown as Newsletter, + }; + } + + return { success: false, message: response.message ?? '周报状态更新失败' }; + } catch (error: unknown) { + console.error('更新周报状态异常:', error); + return { success: false, message: getErrorMessage(error) }; + } +}; diff --git a/src/pages/newsletter/[id]/edit.tsx b/src/pages/newsletter/[id]/edit.tsx new file mode 100644 index 00000000..3e2297b6 --- /dev/null +++ b/src/pages/newsletter/[id]/edit.tsx @@ -0,0 +1,330 @@ +import { useCallback, useEffect, useState } from 'react'; +import { App as AntdApp, Button, Card, Form, Input, Tag } from 'antd'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; +import { + ArrowLeft, + FileText, + ImageIcon, + Plus, + Save, + Users, +} from 'lucide-react'; +import VditorEditor from '@/components/vditorEditor/VditorEditor'; +import UploadCardImg from '@/components/uploadCardImg/UploadCardImg'; +import { + Newsletter, + getNewsletterById, + updateNewsletter, +} from '@/pages/api/newsletter'; +import styles from '../../blogs/[id]/edit.module.css'; + +const { TextArea } = Input; + +type CloudinaryImage = { + secure_url?: string; +}; + +type NewsletterFormValues = { + title?: string; + description?: string; + content?: string; + source?: string; + author?: string; + translator?: string; +}; + +export default function EditNewsletterPage() { + const router = useRouter(); + const { message } = AntdApp.useApp(); + const [form] = Form.useForm(); + const { id } = router.query; + const newsletterId = Array.isArray(id) ? id[0] : id; + + const [newsletter, setNewsletter] = useState(); + const [loading, setLoading] = useState(true); + const [tags, setTags] = useState([]); + const [inputVisible, setInputVisible] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [previewUrl, setPreviewUrl] = useState(''); + const [cloudinaryImg, setCloudinaryImg] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleVditorEditorChange = useCallback( + (value: string) => { + form.setFieldValue('content', value); + }, + [form] + ); + + const handleSubmit = async (values: NewsletterFormValues) => { + if (!newsletter) return; + + try { + setIsSubmitting(true); + const result = await updateNewsletter(newsletter.ID.toString(), { + title: values.title || '', + description: values.description || '', + content: values.content || '', + source_link: values.source || '', + cover_img: cloudinaryImg?.secure_url || previewUrl, + tags, + author: values.author || '', + translator: values.translator || '', + }); + + if (result.success) { + message.success('周报更新成功'); + router.push('/newsletter'); + } else { + message.error(result.message || '更新周报失败'); + } + } catch (error) { + console.error('更新周报失败:', error); + message.error('更新周报出错,请重试'); + } finally { + setIsSubmitting(false); + } + }; + + const handleAddTag = () => { + const normalizedTag = inputValue.trim(); + if (normalizedTag && !tags.includes(normalizedTag)) { + setTags([...tags, normalizedTag]); + } + setInputValue(''); + setInputVisible(false); + }; + + const handleRemoveTag = (tagToRemove: string) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + useEffect(() => { + if (!router.isReady || !newsletterId) return; + + const fetchNewsletter = async () => { + setLoading(true); + try { + const response = await getNewsletterById(newsletterId); + const data = response.data; + + if (response.success && data) { + setNewsletter(data); + form.setFieldsValue({ + title: data.title, + description: data.description, + content: data.content, + source: data.source_link, + cover: data.cover_img, + author: data.author, + translator: data.translator || '', + }); + setPreviewUrl(data.cover_img || ''); + setTags(data.tags || []); + } else { + setNewsletter(null); + } + } catch { + message.error('加载周报失败'); + setNewsletter(null); + } finally { + setLoading(false); + } + }; + + fetchNewsletter(); + }, [router.isReady, newsletterId, form, message]); + + if (!loading && !newsletter) { + return ( +
+

周报不存在

+

抱歉,找不到您要编辑的周报

+ + 返回周报列表 + +
+ ); + } + + return ( +
+
+ + + 返回周报列表 + +
+ +
+
+
+ +

+ + 基本信息 +

+ + + + + + +