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 中文社区 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 ( +
+
+ + + 返回周报列表 + +
+ +
+
+
+ +

+ + 基本信息 +

+ + + + + + +