- {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..6aa1dd4 100644
--- a/src/components/post/PostForm.tsx
+++ b/src/components/post/PostForm.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { useRouter } from "next/navigation";
-import { ImagePlus } from "lucide-react";
+import { ImagePlus, Paperclip, X, Loader2 } from "lucide-react";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -12,16 +12,37 @@ import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/components/ui/toast-context";
import { toQueryRoute } from "@/lib/query-routing";
import { useAuth } from "@/contexts/AuthContext";
+import { PostSection, POST_SECTION_LABEL } from "@/lib/enums";
type Mode = "create" | "edit";
type Initial = Partial<{
title: string;
body: string;
+ section: string;
visibility: string;
status: string;
}>;
+type UploadedFile = {
+ url: string;
+ previewUrl: string;
+ name: string;
+ size: number;
+ type: "image" | "attachment";
+};
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function displayName(name: string): string {
+ const dotIdx = name.lastIndexOf(".");
+ return dotIdx > 0 ? name.slice(0, dotIdx) : name;
+}
+
export function PostForm({
mode,
postId,
@@ -38,49 +59,94 @@ 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 isDraftEdit = mode === "edit" && initial?.status === "DRAFT";
const isAdmin = user?.tier === "ADMIN";
const [submitting, setSubmitting] = React.useState(false);
+
+ const [uploads, setUploads] = React.useState([]);
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 blobUrlsRef = React.useRef([]);
- 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\n`);
- toast({ title: "图片已插入", variant: "success" });
- } catch (err) {
- const msg = err instanceof Error ? err.message : "上传失败";
- toast({ title: "图片上传失败", description: msg, variant: "danger" });
- } finally {
- setUploading(false);
+ React.useEffect(() => {
+ return () => {
+ for (const url of blobUrlsRef.current) {
+ URL.revokeObjectURL(url);
+ }
+ };
+ }, []);
+
+ const uploadFile = React.useCallback(
+ async (file: File, purpose: string) => {
+ const isImage = file.type.startsWith("image/");
+ const type = isImage ? "image" as const : "attachment" as const;
+ const maxSize = isImage ? 8 * 1024 * 1024 : 20 * 1024 * 1024;
+ if (file.size > maxSize) {
+ toast({
+ title: `文件过大,${isImage ? "图片" : "附件"}不能超过 ${isImage ? "8MB" : "20MB"}`,
+ variant: "danger",
+ });
+ return;
+ }
+
+ const blobUrl = URL.createObjectURL(file);
+ blobUrlsRef.current.push(blobUrl);
+
+ setUploading(true);
+ try {
+ const res = await api.files.upload(file, purpose);
+ const entry: UploadedFile = {
+ url: res.url,
+ previewUrl: blobUrl,
+ name: file.name,
+ size: file.size,
+ type,
+ };
+ setUploads((prev) => [...prev, entry]);
+
+ const markdown = isImage
+ ? ``
+ : `[${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) {
+ URL.revokeObjectURL(blobUrl);
+ blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== blobUrl);
+ const msg = err instanceof Error ? err.message : "上传失败";
+ toast({ title: "上传失败", description: msg, variant: "danger" });
+ } finally {
+ setUploading(false);
+ }
+ },
+ [body.length, toast],
+ );
+
+ function removeUpload(idx: number) {
+ const removed = uploads[idx];
+ if (removed) {
+ URL.revokeObjectURL(removed.previewUrl);
+ blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== removed.previewUrl);
}
+ setUploads((prev) => prev.filter((_, i) => i !== idx));
}
async function submit(
@@ -92,7 +158,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 +172,7 @@ export function PostForm({
} else {
toast({
title: "已提交",
- description: "我们正在审核,通过后会出现在广场。可以去通知页面看进度。",
+ description: "我们正在审核,通过后会出现在广场。可以去通知页面看进度。",
variant: "success",
});
router.push(toQueryRoute(`/posts/${res.id}`));
@@ -152,32 +218,13 @@ export function PostForm({
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
{
+ const f = e.target.files?.[0];
+ if (f) {
+ uploadFile(f, "post-image");
+ e.target.value = "";
+ }
+ }}
+ />
+
{
+ const f = e.target.files?.[0];
+ if (f) {
+ uploadFile(f, "post-attachment");
+ e.target.value = "";
+ }
+ }}
+ />
+ {uploading && (
+
+
+ 上传中…
+
+ )}
+
+ 图片 8MB · 附件 20MB
+
+
+
+ {uploads.length > 0 && (
+
+
已插入的文件
+
+ {uploads.map((f, idx) => (
+
+ {f.type === "image" ? (
+
+

+
+ ) : (
+
+
+
+
+ {displayName(f.name)}
+
+
{formatSize(f.size)}
+
+
+ )}
+
+
+ ))}
+
+
+ )}