diff --git a/gmonad/middlewares/jwt.go b/gmonad/middlewares/jwt.go
index 10cadb52..4d2a6ad5 100644
--- a/gmonad/middlewares/jwt.go
+++ b/gmonad/middlewares/jwt.go
@@ -34,7 +34,8 @@ func JWT(permission string) gin.HandlerFunc {
return
}
- perms, err := models.GetUserWithPermissions(claims.Uid)
+ userID := uint(claims.Uid)
+ perms, err := models.GetUserWithPermissions(userID)
if err != nil {
utils.ErrorResponse(c, http.StatusUnauthorized, "Unauthorized action", nil)
c.Abort()
@@ -57,7 +58,7 @@ func JWT(permission string) gin.HandlerFunc {
}
}
- c.Set("uid", claims.Uid)
+ c.Set("uid", userID)
c.Set("permissions", claims.Permissions)
c.Next()
}
diff --git a/gmonad/utils/jwt.go b/gmonad/utils/jwt.go
index 46f7ad38..5612076a 100644
--- a/gmonad/utils/jwt.go
+++ b/gmonad/utils/jwt.go
@@ -1,7 +1,11 @@
package utils
import (
+ "bytes"
+ "encoding/json"
"errors"
+ "fmt"
+ "strconv"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -11,9 +15,38 @@ import (
// JWT 密钥
var jwtSecret = viper.GetString("jwt.secret")
+type UserID uint
+
+func (u *UserID) UnmarshalJSON(data []byte) error {
+ var raw any
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.UseNumber()
+ if err := decoder.Decode(&raw); err != nil {
+ return err
+ }
+
+ switch value := raw.(type) {
+ case json.Number:
+ return u.setFromString(value.String())
+ case string:
+ return u.setFromString(value)
+ default:
+ return fmt.Errorf("invalid uid type: %T", value)
+ }
+}
+
+func (u *UserID) setFromString(value string) error {
+ parsed, err := strconv.ParseUint(value, 10, 0)
+ if err != nil {
+ return fmt.Errorf("invalid uid: %q", value)
+ }
+ *u = UserID(parsed)
+ return nil
+}
+
// 结构体定义 JWT 负载
type Claims struct {
- Uid uint `json:"uid"`
+ Uid UserID `json:"uid"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Username string `json:"username"`
@@ -26,7 +59,7 @@ type Claims struct {
func GenerateToken(uid uint, email, avatar, username, github string, permissions []string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour * 7)
claims := Claims{
- Uid: uid,
+ Uid: UserID(uid),
Email: email,
Avatar: avatar,
Username: username,
diff --git a/next.config.ts b/next.config.ts
index 11ff4ae9..af57e914 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,14 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
+ async rewrites() {
+ return [
+ {
+ source: '/v1/:path*',
+ destination: 'https://gmonad.cc/api/v1/:path*',
+ },
+ ];
+ },
typescript: {
ignoreBuildErrors: true, // 忽略 TypeScript 检查
},
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 中文社区
@@ -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 (
+
+ );
+}
diff --git a/src/pages/newsletter/[id]/index.tsx b/src/pages/newsletter/[id]/index.tsx
new file mode 100644
index 00000000..271f0b88
--- /dev/null
+++ b/src/pages/newsletter/[id]/index.tsx
@@ -0,0 +1,231 @@
+import { useEffect, useState } from 'react';
+import { App as AntdApp, Button, Image, Tag } from 'antd';
+import { GetServerSideProps } from 'next';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import dayjs from 'dayjs';
+import { ArrowLeft, CheckCircle, Edit, Eye } from 'lucide-react';
+import SEO from '@/components/SEO';
+import { useAuth } from '@/contexts/AuthContext';
+import { sanitizeMarkdown } from '@/lib/markdown';
+import {
+ Newsletter,
+ NEWSLETTER_CATEGORY,
+ updateNewsletterPublishStatus,
+} from '@/pages/api/newsletter';
+import styles from '../../blogs/[id]/index.module.css';
+
+const formatTime = (isoTime: string) => dayjs(isoTime).format('YYYY-MM-DD HH:mm');
+
+interface NewsletterDetailPageProps {
+ initialNewsletter: Newsletter | null;
+}
+
+export default function NewsletterDetailPage({
+ initialNewsletter,
+}: NewsletterDetailPageProps) {
+ const router = useRouter();
+ const { message } = AntdApp.useApp();
+ const { session, status } = useAuth();
+ const permissions = session?.user?.permissions || [];
+ const [newsletter] = useState(initialNewsletter);
+ const [content, setContent] = useState('');
+
+ useEffect(() => {
+ if (newsletter?.content) {
+ sanitizeMarkdown(newsletter.content).then(setContent);
+ }
+ }, [newsletter?.content]);
+
+ const handleUpdatePublishStatus = async () => {
+ if (!newsletter) return;
+ try {
+ const result = await updateNewsletterPublishStatus(
+ newsletter.ID.toString(),
+ 2
+ );
+ if (result.success) {
+ message.success('周报审核通过');
+ router.reload();
+ } else {
+ message.error(result.message || '审核周报失败');
+ }
+ } catch {
+ message.error('审核周报出错,请重试');
+ }
+ };
+
+ const isUnderReview = newsletter?.publish_status === 1;
+ const isPublisher =
+ newsletter?.publisher_id?.toString() === session?.user?.uid;
+ const canReview = permissions.includes('blog:review');
+
+ if (!newsletter || (isUnderReview && !isPublisher && !canReview)) {
+ return (
+
+
周报不存在
+
抱歉,找不到您要查看的周报
+
+ 返回周报列表
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ 返回周报列表
+
+
+ {status === 'authenticated' && isPublisher ? (
+ }
+ className={styles.actionButton}
+ onClick={() => router.push(`/newsletter/${newsletter.ID}/edit`)}
+ >
+ 编辑
+
+ ) : null}
+ {newsletter.publish_status === 1 &&
+ status === 'authenticated' &&
+ canReview ? (
+ }
+ className={styles.actionButton}
+ onClick={handleUpdatePublishStatus}
+ >
+ 审核通过
+
+ ) : null}
+
+
+
+
+
+
+
+ {newsletter.publish_status === 1 ? (
+
+ 待审核
+
+ ) : null}
+
{newsletter.title}
+
{newsletter.description}
+
+ {newsletter.source_link ? (
+
+ 查看原文 →
+
+ ) : null}
+
+
+ {(newsletter.tags || []).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {newsletter.author || newsletter.publisher?.username || ''}
+ ·
+ {formatTime(newsletter.publish_time || newsletter.CreatedAt)}
+ {newsletter.translator ? (
+ <>
+ ·
+ 整理 / 翻译:{newsletter.translator}
+ >
+ ) : null}
+ ·
+
+
+ {newsletter.view_count || 0}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export const getServerSideProps: GetServerSideProps<
+ NewsletterDetailPageProps
+> = async (context) => {
+ const { id } = context.params as { id: string };
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL;
+
+ if (!apiUrl) {
+ return { props: { initialNewsletter: null } };
+ }
+
+ try {
+ const response = await fetch(`${apiUrl}/blogs/${id}`, {
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ if (!response.ok) {
+ return { props: { initialNewsletter: null } };
+ }
+
+ const result = await response.json();
+ const newsletter =
+ result.code === 200 ? (result.data as Newsletter) : null;
+
+ return {
+ props: {
+ initialNewsletter:
+ newsletter?.category === NEWSLETTER_CATEGORY ? newsletter : null,
+ },
+ };
+ } catch (error) {
+ console.error('Error fetching newsletter:', error);
+ return { props: { initialNewsletter: null } };
+ }
+};
diff --git a/src/pages/newsletter/index.tsx b/src/pages/newsletter/index.tsx
index 5a1fe199..7102a72c 100644
--- a/src/pages/newsletter/index.tsx
+++ b/src/pages/newsletter/index.tsx
@@ -1,150 +1,418 @@
-import React from 'react';
-import { SiTelegram, SiX } from 'react-icons/si';
-import { ArrowRight, HelpCircle, Mail, Megaphone } from 'lucide-react';
-import styles from './index.module.css';
-
-export default function NewsletterSection() {
- const weeklyReports = [
- {
- title: 'Monad 周报 #12:性能测试新突破',
- date: '2025年9月26日',
- link: '#',
- },
- {
- title: 'Monad 周报 #11:生态项目 AMA 回顾',
- date: '2025年9月19日',
- link: '#',
- },
- {
- title: 'Monad 周报 #10:开发者激励计划启动',
- date: '2025年9月12日',
- link: '#',
- },
- ];
-
- const faqs = [
- 'MonadDB 与其他数据库有何不同?',
- 'Monad 客户端是用什么语言开发的?',
- '为什么 Monad 选择开发为 L1 区块链?',
- ];
+import { useCallback, useEffect, useState } from 'react';
+import {
+ App as AntdApp,
+ Button,
+ Card,
+ Image,
+ Input,
+ Pagination,
+ Popconfirm,
+ Tag,
+} from 'antd';
+import dayjs from 'dayjs';
+import {
+ Calendar,
+ Edit,
+ Eye,
+ LayoutGrid,
+ List,
+ Plus,
+ Share2,
+ Trash2,
+ UserRound,
+} from 'lucide-react';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useAuth } from '@/contexts/AuthContext';
+import {
+ Newsletter,
+ deleteNewsletter,
+ getNewsletters,
+} from '../api/newsletter';
+import styles from '../blogs/index.module.css';
+
+const { Search: AntSearch } = Input;
+
+type ViewMode = 'grid' | 'list';
+
+const formatTime = (isoTime?: string) =>
+ isoTime ? dayjs(isoTime).format('YYYY-MM-DD HH:mm') : '';
+
+export default function NewsletterPage() {
+ const router = useRouter();
+ const { message } = AntdApp.useApp();
+ const { session, status } = useAuth();
+ const permissions = session?.user?.permissions || [];
+ const canReview = permissions.includes('blog:review');
+ const canCreate = status === 'authenticated' && permissions.includes('blog:write');
+
+ const [viewMode, setViewMode] = useState('grid');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(6);
+ const [newsletters, setNewsletters] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [publishStatus, setPublishStatus] = useState(2);
+
+ // 周报列表通过独立的 newsletter API 封装获取,避免页面直接依赖博客 API。
+ const loadNewsletters = useCallback(async (params?: {
+ keyword?: string;
+ page?: number;
+ page_size?: number;
+ publish_status?: number;
+ }) => {
+ try {
+ setLoading(true);
+ const result = await getNewsletters({
+ keyword: params?.keyword ?? searchKeyword,
+ order: 'desc',
+ page: params?.page ?? currentPage,
+ page_size: params?.page_size ?? pageSize,
+ publish_status: params?.publish_status ?? publishStatus,
+ });
+
+ if (result.success && result.data?.newsletters) {
+ setNewsletters(result.data.newsletters);
+ setCurrentPage(result.data.page || 1);
+ setPageSize(result.data.page_size || 6);
+ setTotal(result.data.total || result.data.newsletters.length);
+ return;
+ }
+
+ setNewsletters([]);
+ setTotal(0);
+ } catch (error) {
+ console.error('加载周报列表异常:', error);
+ setNewsletters([]);
+ setTotal(0);
+ } finally {
+ setLoading(false);
+ }
+ }, [currentPage, pageSize, publishStatus, searchKeyword]);
+
+ const handleSearch = async (keyword: string) => {
+ setSearchKeyword(keyword);
+ setCurrentPage(1);
+ await loadNewsletters({ keyword, page: 1 });
+ };
+
+ const handlePageChange = async (page: number, size?: number) => {
+ setCurrentPage(page);
+ if (size && size !== pageSize) setPageSize(size);
+ await loadNewsletters({ page, page_size: size || pageSize });
+ };
+
+ const handleDeleteNewsletter = async (id: number) => {
+ try {
+ const result = await deleteNewsletter(id);
+ if (result.success) {
+ message.success(result.message);
+ await loadNewsletters();
+ } else {
+ message.error(result.message || '删除周报失败');
+ }
+ } catch {
+ message.error('删除周报出错,请重试');
+ }
+ };
+
+ const handleShare = async (id: number) => {
+ await navigator.clipboard.writeText(`${window.location.origin}/newsletter/${id}`);
+ message.success('周报链接已复制到剪贴板');
+ };
+
+ const startIndex = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
+ const endIndex = Math.min(currentPage * pageSize, total);
+
+ useEffect(() => {
+ if (status === 'loading') return;
+ const nextPublishStatus = status === 'authenticated' && canReview ? 0 : 2;
+ setPublishStatus(nextPublishStatus);
+ loadNewsletters({ publish_status: nextPublishStatus });
+ }, [status, canReview, loadNewsletters]);
return (
-
-
- {/* Weekly Reports Section */}
-
-
-
-
Monad 中文社区周报
-
-
- 精选社区每周的技术进展、生态更新和重要动态。
-
-
-
-
- 查看所有周报
-
+
+
+
+
+
周报
+
聚合 Monad 中文社区每周重点动态
+ {canCreate ? (
+
+
+ 创建周报
+
+ ) : null}
+
+
+
+
+
+
setSearchKeyword(e.target.value)}
+ onSearch={handleSearch}
+ onClear={() => handleSearch('')}
+ loading={loading}
+ />
+
- {/* FAQ and Community CTA Section */}
-
-
-
-
常见技术问题 (FAQ)
-
-
- {faqs.map((faq, index) => (
- -
- {faq}
-
- ))}
-
-
-
- 还有其他问题吗?立即加入 Monad 中文社区,参与我们的技术讨论。
-
-
+
+
+
+
+
+
`显示 ${startIndex}-${endIndex} 项,共 ${total} 项`}
+ className={styles.fullPagination}
+ />
+
+
+ {loading ? (
+
+ ) : newsletters.length === 0 ? (
+
+
📰
+
暂无周报
+
+ {searchKeyword ? '没有找到符合条件的周报' : '还没有创建任何周报'}
+ {canCreate ? (
+
+
+ 创建第一篇周报
+
+ ) : null}
+ ) : viewMode === 'grid' ? (
+
+ {newsletters.map((newsletter) => {
+ const isPublisher =
+ newsletter.publisher_id?.toString() === session?.user?.uid;
- {/* Monad Memo / Footer Links */}
-
-
Monad Memo
-
-
-
-
-
生态项目
-
+ return (
+
+
+
+
+ {newsletter.publish_status === 1 ? (
+
待审核
+ ) : null}
+
+ {status === 'authenticated' && isPublisher ? (
+
+
+
+ }
+ >
+
+
{newsletter.title}
+
+ {newsletter.description}
+
+
+
+
+
+
+ {newsletter.publisher?.username || newsletter.author || ''}
+
+
+ {dayjs(newsletter.publish_time || newsletter.CreatedAt).format(
+ 'YYYY年M月D日'
+ )}
+
+
+
+
+
+ {newsletter.view_count || 0}
+
+
+
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+
周报信息
+
作者
+
时间
+
浏览量
+
状态
+
操作
+ {newsletters.map((newsletter) => {
+ const isPublisher =
+ newsletter.publisher_id?.toString() === session?.user?.uid;
+
+ return (
+
+
+
+ {newsletter.title}
+
+
+
+
+
+ {newsletter.author || newsletter.publisher?.username || ''}
+
+
+
+
+
+
+ {formatTime(newsletter.publish_time || newsletter.CreatedAt)}
+
+
+
+
+
+
+
+ {newsletter.view_count || 0}
+
+
+
+
+ {newsletter.publish_status === 1 ? (
+ 待审核
+ ) : null}
+ {newsletter.publish_status === 2 ? (
+ 已发布
+ ) : null}
+
+
+
+ {status === 'authenticated' && isPublisher ? (
+
}
+ title="编辑周报"
+ onClick={() => router.push(`/newsletter/${newsletter.ID}/edit`)}
+ />
+ ) : null}
+
}
+ title="分享周报"
+ onClick={() => handleShare(newsletter.ID)}
+ />
+ {status === 'authenticated' && isPublisher ? (
+
handleDeleteNewsletter(newsletter.ID)}
+ >
+ }
+ title="删除周报"
+ />
+
+ ) : null}
+
+
+
+ );
+ })}
+ )}
-
+
+
+
`显示 ${startIndex}-${endIndex} 项,共 ${total} 项`}
+ className={styles.fullPagination}
+ />
+
-
+
);
}
diff --git a/src/pages/newsletter/new.tsx b/src/pages/newsletter/new.tsx
new file mode 100644
index 00000000..58dab17d
--- /dev/null
+++ b/src/pages/newsletter/new.tsx
@@ -0,0 +1,292 @@
+import { useCallback, useState } from 'react';
+import { App as AntdApp, Button, Card, Form, Input, Select, Tag } from 'antd';
+import {
+ ArrowLeft,
+ FileText,
+ ImageIcon,
+ Plus,
+ Save,
+ Users,
+} from 'lucide-react';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import VditorEditor from '@/components/vditorEditor/VditorEditor';
+import UploadCardImg from '@/components/uploadCardImg/UploadCardImg';
+import { createNewsletter } from '../api/newsletter';
+import styles from '../blogs/new.module.css';
+
+const { TextArea } = Input;
+
+type CloudinaryImage = {
+ secure_url?: string;
+};
+
+type NewsletterFormValues = {
+ title?: string;
+ description?: string;
+ content?: string;
+ source?: string;
+ sourceType?: string;
+ author?: string;
+ translator?: string;
+};
+
+export default function NewNewsletterPage() {
+ const router = useRouter();
+ const { message } = AntdApp.useApp();
+ const [form] = Form.useForm();
+
+ 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) => {
+ try {
+ setIsSubmitting(true);
+ const result = await createNewsletter({
+ title: values.title || '',
+ description: values.description || '',
+ content: values.content || '',
+ source_link: values.source || '',
+ source_type: values.sourceType || 'official',
+ cover_img: cloudinaryImg?.secure_url || '',
+ 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));
+ };
+
+ return (
+
+
+
+
+
新建周报
+
+
+
+
+ );
+}