diff --git a/src/app/(app)/posts/PostDetailPageClient.tsx b/src/app/(app)/posts/PostDetailPageClient.tsx index 96ad0ee..4b5d641 100644 --- a/src/app/(app)/posts/PostDetailPageClient.tsx +++ b/src/app/(app)/posts/PostDetailPageClient.tsx @@ -7,7 +7,6 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { Trash2, ArrowLeft, Pencil, Heart, Bookmark, UserPlus, UserMinus } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import rehypeSanitize from "rehype-sanitize"; import { api, type Post } from "@/lib/api"; import { useAuth } from "@/contexts/AuthContext"; import { Badge } from "@/components/ui/badge"; @@ -16,7 +15,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { CommentSection } from "@/components/post/CommentSection"; import { ReportButton } from "@/components/moderation/ReportButton"; import { useToast } from "@/components/ui/toast-context"; -import { TAG_LABEL, parsePostTags } from "@/lib/post-tags"; +import { POST_SECTION_LABEL } from "@/lib/enums"; import { relativeTime } from "@/lib/utils"; import { getQueryRoute, toQueryRoute } from "@/lib/query-routing"; @@ -61,7 +60,6 @@ function PostDetailInner() { const rejected = post.status === "REJECTED"; const hidden = post.status === "HIDDEN"; const draft = post.status === "DRAFT"; - const tags = parsePostTags(post.tags); async function remove() { if (!id) return; @@ -182,17 +180,11 @@ function PostDetailInner() { ) : null}
-
- {tags.length > 0 ? ( - tags.map((t) => ( - - {TAG_LABEL[t] ?? t} - - )) - ) : ( - 动态 - )} - {post.section === "MEDICAL" && post.hospital ? ( +
+ + {POST_SECTION_LABEL[post.section as keyof typeof POST_SECTION_LABEL] ?? "动态"} + + {post.section === "MEDICAL" && post.hospital ? ( {post.hospital} ) : null}
@@ -221,7 +213,18 @@ function PostDetailInner() { ) : null}
- + { + if (!src) return null; + const resolved = src.startsWith("/api/files/") ? api.files.url(src) : src; + return {alt; + }, + a: ({ href, children, ...props }) => { + if (!href) return {children}; + const resolved = href.startsWith("/api/files/") ? api.files.url(href) : href; + return {children}; + } + }}> {post.body}
diff --git a/src/app/(app)/posts/new/page.tsx b/src/app/(app)/posts/new/page.tsx index 96e803c..2651a17 100644 --- a/src/app/(app)/posts/new/page.tsx +++ b/src/app/(app)/posts/new/page.tsx @@ -32,7 +32,7 @@ function NewPostInner() { diff --git a/src/components/post/PostCard.tsx b/src/components/post/PostCard.tsx index 7c5516a..2088224 100644 --- a/src/components/post/PostCard.tsx +++ b/src/components/post/PostCard.tsx @@ -5,7 +5,7 @@ import { MessageCircle, Heart } from "lucide-react"; import type { Post } from "@/lib/api"; import { relativeTime } from "@/lib/utils"; import { toQueryRoute } from "@/lib/query-routing"; -import { TAG_LABEL, parsePostTags } from "@/lib/post-tags"; +import { POST_SECTION_LABEL } from "@/lib/enums"; type CardPost = Post & { like_count?: number }; @@ -14,7 +14,6 @@ export function PostCard({ post }: { post: CardPost }) { const isHidden = post.status === "HIDDEN"; const isRejected = post.status === "REJECTED"; const isDraft = post.status === "DRAFT"; - const tags = parsePostTags(post.tags); // The list endpoint exposes `like_count` (snake_case); the detail endpoint // exposes `likeCount` (camelCase). Read both so the card works in either // context (list views, bookmarks, drafts). @@ -25,15 +24,9 @@ export function PostCard({ post }: { post: CardPost }) {
- {tags.length > 0 ? ( - tags.slice(0, 3).map((t) => ( - - {TAG_LABEL[t] ?? t} - - )) - ) : ( - 动态 - )} + + {POST_SECTION_LABEL[post.section as keyof typeof POST_SECTION_LABEL] ?? "动态"} + {post.section === "MEDICAL" && post.hospital ? ( {post.hospital} ) : null} diff --git a/src/components/post/PostForm.tsx b/src/components/post/PostForm.tsx index 12c71a7..971bbf3 100644 --- a/src/components/post/PostForm.tsx +++ b/src/components/post/PostForm.tsx @@ -2,7 +2,9 @@ import * as React from "react"; import { useRouter } from "next/navigation"; -import { ImagePlus } from "lucide-react"; +import { ImagePlus, Paperclip, Loader2, Eye, Edit3 } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { api } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,10 +20,14 @@ type Mode = "create" | "edit"; type Initial = Partial<{ title: string; body: string; + section: string; visibility: string; status: string; }>; +const IMAGE_MAX_SIZE = 5 * 1024 * 1024; +const ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024; + export function PostForm({ mode, postId, @@ -38,50 +44,61 @@ export function PostForm({ const [title, setTitle] = React.useState(initial?.title ?? ""); const [body, setBody] = React.useState(initial?.body ?? ""); + const [section, setSection] = React.useState(initial?.section ?? "QUESTION"); const [visibility, setVisibility] = React.useState(initial?.visibility ?? "VERIFIED"); + const [previewMode, setPreviewMode] = React.useState(false); const isDraftEdit = mode === "edit" && initial?.status === "DRAFT"; const isAdmin = user?.tier === "ADMIN"; const [submitting, setSubmitting] = React.useState(false); const [uploading, setUploading] = React.useState(false); + const fileInputRef = React.useRef(null); + const attachmentInputRef = React.useRef(null); - function insertAtCursor(snippet: string) { - const ta = bodyRef.current; - if (!ta) { - setBody((prev) => prev + snippet); - return; - } - const start = ta.selectionStart ?? body.length; - const end = ta.selectionEnd ?? body.length; - const next = body.slice(0, start) + snippet + body.slice(end); - setBody(next); - requestAnimationFrame(() => { - ta.focus(); - const caret = start + snippet.length; - ta.setSelectionRange(caret, caret); - }); - } + const uploadFile = React.useCallback( + async (file: File, purpose: string) => { + const isImage = file.type.startsWith("image/"); + const maxSize = isImage ? IMAGE_MAX_SIZE : ATTACHMENT_MAX_SIZE; + if (file.size > maxSize) { + toast({ + title: `文件过大,${isImage ? "图片" : "附件"}不能超过 ${isImage ? "5MB" : "10MB"}`, + variant: "danger", + }); + return; + } - async function uploadImage(file: File) { - if (!file.type.startsWith("image/")) { - toast({ title: "请选择图片文件", variant: "danger" }); - return; - } - if (file.size > 5 * 1024 * 1024) { - toast({ title: "图片大小请控制在 5MB 以内", variant: "danger" }); - return; - } - setUploading(true); - try { - const res = await api.files.upload(file, "post-image"); - insertAtCursor(`\n![](${res.url})\n`); - toast({ title: "图片已插入", variant: "success" }); - } catch (err) { - const msg = err instanceof Error ? err.message : "上传失败"; - toast({ title: "图片上传失败", description: msg, variant: "danger" }); - } finally { - setUploading(false); - } - } + setUploading(true); + try { + const res = await api.files.upload(file, purpose); + + const markdown = isImage + ? `![${file.name}](${res.url})` + : `[${file.name}](${res.url})`; + const ta = bodyRef.current; + if (ta) { + const start = ta.selectionStart ?? body.length; + const end = ta.selectionEnd ?? body.length; + const prefix = start > 0 && body[start - 1] !== "\n" ? "\n" : ""; + const snippet = `${prefix}${markdown}\n`; + const next = body.slice(0, start) + snippet + body.slice(end); + setBody(next); + requestAnimationFrame(() => { + ta.focus(); + const caret = start + snippet.length; + ta.setSelectionRange(caret, caret); + }); + } else { + setBody((prev) => prev + `\n${markdown}\n`); + } + toast({ title: isImage ? "图片已插入" : "附件已插入", variant: "success" }); + } catch (err) { + const msg = err instanceof Error ? err.message : "上传失败"; + toast({ title: "上传失败", description: msg, variant: "danger" }); + } finally { + setUploading(false); + } + }, + [body.length, toast], + ); async function submit( e: React.FormEvent, @@ -92,7 +109,7 @@ export function PostForm({ if (submitting) return; setSubmitting(true); try { - const payload = { title: title.trim(), body: body.trim(), visibility }; + const payload = { title: title.trim(), body: body.trim(), section, visibility }; if (mode === "create") { const res = await api.posts.create(payload, asDraft); @@ -106,7 +123,7 @@ export function PostForm({ } else { toast({ title: "已提交", - description: "我们正在审核,通过后会出现在广场。可以去通知页面看进度。", + description: "我们正在审核,通过后会出现在广场。可以去通知页面看进度。", variant: "success", }); router.push(toQueryRoute(`/posts/${res.id}`)); @@ -154,37 +171,133 @@ export function PostForm({
- +
+ + +
-