From 42f8869e0e24af3c3b0e5d9bcb4a4322bf65c0ad Mon Sep 17 00:00:00 2001 From: ZTZK <2802227956@qq.com> Date: Fri, 29 May 2026 08:32:17 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=92=8C=E5=8F=91=E5=B8=96=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - files.ts: 上传接口返回绝对URL而非相对路径,解决跨端口图片加载问题 - PostDetailPageClient.tsx: 自定义img组件,处理相对/绝对URL兼容, 添加图片样式,移除rehype-sanitize避免意外过滤 - 0008 migration: 更新posts表section CHECK约束,允许新分类值 (QUESTION/OFFLINE_MEETUP/REFLECTION) --- src/app/(app)/posts/PostDetailPageClient.tsx | 10 ++++- .../0008_update_posts_section_check.sql | 38 +++++++++++++++++++ worker/src/routes/files.ts | 2 +- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 worker/migrations/0008_update_posts_section_check.sql diff --git a/src/app/(app)/posts/PostDetailPageClient.tsx b/src/app/(app)/posts/PostDetailPageClient.tsx index 96ad0ee..7e0a257 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"; @@ -221,7 +220,14 @@ function PostDetailInner() { ) : null}
- + { + if (!src) return null; + const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_FALLBACK_URL; + const resolved = apiUrl && src.startsWith("/") ? `${apiUrl}${src}` : src; + return {alt; + } + }}> {post.body}
diff --git a/worker/migrations/0008_update_posts_section_check.sql b/worker/migrations/0008_update_posts_section_check.sql new file mode 100644 index 0000000..acf69d0 --- /dev/null +++ b/worker/migrations/0008_update_posts_section_check.sql @@ -0,0 +1,38 @@ +-- Migration 0008: Update posts section CHECK to match the new taxonomy +-- (QUESTION, OFFLINE_MEETUP, MEDICAL, RESOURCE, REFLECTION + legacy POST) + +CREATE TABLE posts_new ( + id TEXT PRIMARY KEY, + author_id TEXT NOT NULL REFERENCES users(id), + section TEXT NOT NULL CHECK (section IN ('POST','QUESTION','OFFLINE_MEETUP','MEDICAL','RESOURCE','REFLECTION')), + title TEXT NOT NULL, + body TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + hospital TEXT, + doctor TEXT, + city TEXT, + resource_kind TEXT, + cover_url TEXT, + visibility TEXT NOT NULL DEFAULT 'VERIFIED', + status TEXT NOT NULL DEFAULT 'PENDING_REVIEW', + moderation_verdict TEXT, + moderation_reason TEXT, + moderation_categories TEXT, + moderation_raw TEXT, + moderation_classifier TEXT, + moderated_at TEXT, + reviewed_by_id TEXT REFERENCES users(id), + reviewed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO posts_new SELECT * FROM posts; + +DROP TABLE posts; +ALTER TABLE posts_new RENAME TO posts; + +CREATE INDEX idx_posts_section_status ON posts(section, status); +CREATE INDEX idx_posts_author ON posts(author_id); +CREATE INDEX idx_posts_hospital ON posts(hospital); +CREATE INDEX idx_posts_created_at ON posts(created_at); \ No newline at end of file diff --git a/worker/src/routes/files.ts b/worker/src/routes/files.ts index 871d538..637adcb 100644 --- a/worker/src/routes/files.ts +++ b/worker/src/routes/files.ts @@ -43,7 +43,7 @@ files.post("/", requireAuth, async (c) => { customMetadata: { uploaderId, purpose }, }); - return c.json({ ok: true, url: `/api/files/${key}` }); + return c.json({ ok: true, url: `${new URL(c.req.url).origin}/api/files/${key}` }); }); // GET /api/files/* — serve a file from R2 From 8f871d24e1b8472edc21d5f564d1aeccebd33ebc Mon Sep 17 00:00:00 2001 From: ZTZK <2802227956@qq.com> Date: Fri, 29 May 2026 10:14:57 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(posts):=20=E5=8F=91=E5=B8=96?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9D=BF=E5=9D=97=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20blob=20URL=EF=BC=8C=E4=BF=AE=E5=A4=8D=20tags=20=E6=B4=BE?= =?UTF-8?q?=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostForm: 新增板块选择器下拉框(提问求助/线下交友/医疗信息/资源分享/感悟) - PostForm: 图片预览改用 URL.createObjectURL (blob URL),不依赖 R2 服务 - PostForm: 移除上传文件后的预览卡片,简化 UI - Worker posts.ts: 新增 userPickedTags(),根据用户选择的 section 自动派生 tags - Worker posts.ts: create/PATCH 路由优先使用用户传入的 section,LLM 作为 fallback - Worker files.ts: 上传接口返回相对 URL (/api/files/...),跨环境可移植 - PostCard/PostDetail: 始终显示板块标签(POST_SECTION_LABEL),移除冗余 tag 徽章 - api.ts: Post 接口 section 类型补全所有板块 --- src/app/(app)/posts/PostDetailPageClient.tsx | 27 +- src/app/(app)/posts/new/page.tsx | 2 +- src/components/post/PostCard.tsx | 15 +- src/components/post/PostForm.tsx | 287 +++++++++++++++---- src/lib/api.ts | 6 +- worker/src/routes/files.ts | 38 ++- worker/src/routes/posts.ts | 44 ++- 7 files changed, 311 insertions(+), 108 deletions(-) diff --git a/src/app/(app)/posts/PostDetailPageClient.tsx b/src/app/(app)/posts/PostDetailPageClient.tsx index 7e0a257..4b5d641 100644 --- a/src/app/(app)/posts/PostDetailPageClient.tsx +++ b/src/app/(app)/posts/PostDetailPageClient.tsx @@ -15,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"; @@ -60,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; @@ -181,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}
@@ -223,9 +216,13 @@ function PostDetailInner() { { if (!src) return null; - const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_FALLBACK_URL; - const resolved = apiUrl && src.startsWith("/") ? `${apiUrl}${src}` : src; + 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..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![](${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); + 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})` + : `[${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({
-
- - -
+