diff --git a/.env.example b/.env.example index 9a2b614..3c24a75 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,8 @@ # ----------------------------------------------------------------------- # Full base URL of the Cloudflare Worker backend, no trailing slash. -# If left empty or unset the frontend falls back to the default worker: -# https://transcommunity.cyanmint.workers.dev +# If left empty or unset the frontend falls back to: +# https://communityapi.transhistoria.org NEXT_PUBLIC_API_URL=https://communityapi.transhistoria.org # Optional secondary API base URL tried when the primary host is unreachable diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index a12b241..2158780 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -26,7 +26,6 @@ concurrency: jobs: build: name: Build static frontend - if: github.ref_name == github.event.repository.default_branch || startsWith(github.ref, 'refs/heads/copilot/') runs-on: ubuntu-latest steps: - name: Checkout @@ -64,7 +63,6 @@ jobs: deploy: name: Deploy to GitHub Pages - if: github.ref_name == github.event.repository.default_branch || startsWith(github.ref, 'refs/heads/copilot/') needs: build runs-on: ubuntu-latest environment: diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml new file mode 100644 index 0000000..0f8e8b5 --- /dev/null +++ b/.github/workflows/deploy-worker.yml @@ -0,0 +1,51 @@ +name: Deploy Worker API + +on: + push: + branches: ["default", "main"] + paths: + - "worker/**" + - "wrangler.jsonc" + - ".github/workflows/deploy-worker.yml" + workflow_dispatch: + +concurrency: + group: worker-deploy + cancel-in-progress: true + +jobs: + deploy: + name: Migrate + deploy Cloudflare Worker + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Apply remote D1 migrations + run: pnpm run setup + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Deploy worker + run: pnpm exec wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1eaa9d4..129cdd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,6 +224,16 @@ model Event { // EventStatus: DRAFT | PUBLISHED | CANCELLED | FINISHED status String @default("PUBLISHED") + // LLM moderation outcome (migration 0005) + moderationVerdict String? + moderationReason String? + moderationCategories String? + moderationRaw String? + moderationClassifier String? + moderatedAt DateTime? + reviewedById String? + reviewedAt DateTime? + registrations Registration[] comments Comment[] @@ -236,6 +246,51 @@ model Event { @@index([visibility, status]) } +// ============================================================ +// 帖子(POST 普通 / MEDICAL 医疗信息 / RESOURCE 资源分享) +// 与活动并列的发帖系统,由 LLM 自动审核 + 板块分类。 +// ============================================================ + +model Post { + id String @id @default(cuid()) + authorId String + // PostSection: POST | MEDICAL | RESOURCE + section String + title String + body String + // JSON array (free-form tags) + tags String @default("[]") + // MEDICAL-specific + hospital String? + doctor String? + city String? + // RESOURCE-specific: OFFER (technique on offer) | REQUEST (asking for help) + resourceKind String? + coverUrl String? + + // Visibility: PUBLIC | VERIFIED | TRUSTED + visibility String @default("VERIFIED") + // PostStatus: PENDING_REVIEW | PUBLISHED | REJECTED | HIDDEN + status String @default("PENDING_REVIEW") + + moderationVerdict String? + moderationReason String? + moderationCategories String? + moderationRaw String? + moderationClassifier String? + moderatedAt DateTime? + reviewedById String? + reviewedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([section, status]) + @@index([authorId]) + @@index([hospital]) + @@index([createdAt]) +} + model Registration { id String @id @default(cuid()) eventId String @@ -260,8 +315,12 @@ model Registration { model Comment { id String @id @default(cuid()) - eventId String - event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + // Polymorphic: exactly one of eventId or postId is set. + // Schema kept nullable so Prisma can model both targets; + // application code enforces XOR. + eventId String? + event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade) + postId String? authorId String author User @relation(fields: [authorId], references: [id]) body String @@ -273,6 +332,7 @@ model Comment { createdAt DateTime @default(now()) @@index([eventId]) + @@index([postId]) @@index([authorId]) } diff --git a/src/app/(app)/events/[slug]/edit/EditEventPageClient.tsx b/src/app/(app)/events/[slug]/edit/EditEventPageClient.tsx index 3fddc0c..a597308 100644 --- a/src/app/(app)/events/[slug]/edit/EditEventPageClient.tsx +++ b/src/app/(app)/events/[slug]/edit/EditEventPageClient.tsx @@ -21,6 +21,7 @@ type ApiEvent = { id: string; slug: string; title: string; + status: string; description: string; category: string; format: string; @@ -103,6 +104,7 @@ export default function EditEventPageClient() { registrationOpensAt: event.registration_opens_at ?? null, registrationClosesAt: event.registration_closes_at ?? null, visibility: formVisibility, + status: event.status, customQuestions: questions, }} /> diff --git a/src/app/(app)/events/edit/page.tsx b/src/app/(app)/events/edit/page.tsx index e008359..58bff0d 100644 --- a/src/app/(app)/events/edit/page.tsx +++ b/src/app/(app)/events/edit/page.tsx @@ -1,6 +1,6 @@ -import EditEventPageClient from "../[slug]/edit/EditEventPageClient"; +"use client"; -export const metadata = { title: "编辑活动" }; +import EditEventPageClient from "../[slug]/edit/EditEventPageClient"; export default function EditEventStaticPage() { return ; diff --git a/src/app/(app)/events/manage/page.tsx b/src/app/(app)/events/manage/page.tsx index 55b21a6..12b615e 100644 --- a/src/app/(app)/events/manage/page.tsx +++ b/src/app/(app)/events/manage/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import ManageEventPageClient from "../[slug]/manage/ManageEventPageClient"; export default function ManageEventStaticPage() { diff --git a/src/app/(app)/events/register/page.tsx b/src/app/(app)/events/register/page.tsx index d2085d2..d5369ae 100644 --- a/src/app/(app)/events/register/page.tsx +++ b/src/app/(app)/events/register/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import RegisterPageClient from "../[slug]/register/RegisterPageClient"; export default function RegisterEventStaticPage() { diff --git a/src/app/(app)/me/bookmarks/page.tsx b/src/app/(app)/me/bookmarks/page.tsx new file mode 100644 index 0000000..57e593b --- /dev/null +++ b/src/app/(app)/me/bookmarks/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { api, type Post } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty"; +import { PostCard } from "@/components/post/PostCard"; +import { toQueryRoute } from "@/lib/query-routing"; + +export default function MyBookmarksPage() { + const { user, loading } = useAuth(); + const router = useRouter(); + const [posts, setPosts] = React.useState([]); + const [loaded, setLoaded] = React.useState(false); + + React.useEffect(() => { + if (!loading && !user) router.replace(toQueryRoute("/sign-in?callbackUrl=/me/bookmarks")); + }, [loading, user, router]); + + React.useEffect(() => { + if (!user) return; + api.posts + .myBookmarks() + .then((res) => setPosts(res.posts)) + .catch(() => setPosts([])) + .finally(() => setLoaded(true)); + }, [user]); + + if (loading || !user) return null; + + return ( +
+ + {!loaded ? ( +

加载中…

+ ) : posts.length === 0 ? ( + + ) : ( +
+ {posts.map((p) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/me/contacts/page.tsx b/src/app/(app)/me/contacts/page.tsx index 9d0e194..c77569f 100644 --- a/src/app/(app)/me/contacts/page.tsx +++ b/src/app/(app)/me/contacts/page.tsx @@ -1,26 +1,14 @@ "use client"; import * as React from "react"; -import { api } from "@/lib/api"; -import { PageHeader } from "@/components/ui/page-header"; -import { ContactsManager } from "./ContactsManager"; +import { useRouter } from "next/navigation"; +import { toQueryRoute } from "@/lib/query-routing"; export default function MeContactsPage() { - const [contacts, setContacts] = React.useState([]); + const router = useRouter(); - const load = React.useCallback(() => { - api.users.myContacts().then((res: { contacts: unknown[] }) => setContacts(res.contacts)); - }, []); + React.useEffect(() => { + router.replace(toQueryRoute("/me/profile")); + }, [router]); - React.useEffect(() => { load(); }, [load]); - - return ( -
- - -
- ); + return null; } diff --git a/src/app/(app)/me/drafts/page.tsx b/src/app/(app)/me/drafts/page.tsx new file mode 100644 index 0000000..fb9bd49 --- /dev/null +++ b/src/app/(app)/me/drafts/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Pencil, Trash2 } from "lucide-react"; +import { api, type Post } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/toast-context"; +import { relativeTime } from "@/lib/utils"; +import { toQueryRoute } from "@/lib/query-routing"; + +export default function MyDraftsPage() { + const { user, loading } = useAuth(); + const router = useRouter(); + const { toast } = useToast(); + const [posts, setPosts] = React.useState([]); + const [loaded, setLoaded] = React.useState(false); + + const reload = React.useCallback(() => { + api.posts + .myDrafts() + .then((res) => setPosts(res.posts)) + .catch(() => setPosts([])) + .finally(() => setLoaded(true)); + }, []); + + React.useEffect(() => { + if (!loading && !user) router.replace(toQueryRoute("/sign-in?callbackUrl=/me/drafts")); + }, [loading, user, router]); + + React.useEffect(() => { + if (user) reload(); + }, [user, reload]); + + async function remove(id: string) { + if (!confirm("删除这条草稿?")) return; + try { + await api.posts.remove(id); + toast({ title: "已删除", variant: "success" }); + reload(); + } catch (err) { + const msg = err instanceof Error ? err.message : "删除失败"; + toast({ title: "删除失败", description: msg, variant: "danger" }); + } + } + + if (loading || !user) return null; + + return ( +
+ + {!loaded ? ( +

加载中…

+ ) : posts.length === 0 ? ( + + ) : ( +
+ {posts.map((p) => ( + + {/* Whole card body links to the edit page — draft detail is gated by status. */} + + +
+
+

{p.title || "(未命名)"}

+

+ {p.body || "(空草稿)"} +

+
+
+
+ 更新于 {relativeTime(new Date(p.updated_at))} + + 点击继续编辑 + +
+
+ +
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/me/page.tsx b/src/app/(app)/me/page.tsx index 2788f5c..5cc39ba 100644 --- a/src/app/(app)/me/page.tsx +++ b/src/app/(app)/me/page.tsx @@ -28,6 +28,8 @@ export default function MeOverviewPage() { const [allRegs, setAllRegs] = React.useState([]); const [pendingReqs, setPendingReqs] = React.useState(0); const [unreadNotif, setUnreadNotif] = React.useState(0); + const [draftCount, setDraftCount] = React.useState(0); + const [bookmarkCount, setBookmarkCount] = React.useState(0); React.useEffect(() => { if (!user) return; @@ -60,6 +62,8 @@ export default function MeOverviewPage() { api.notifications.list(true).then((res: { notifications: unknown[] }) => { setUnreadNotif(res.notifications.length); }); + api.posts.myDrafts().then((res) => setDraftCount(res.posts.length)).catch(() => setDraftCount(0)); + api.posts.myBookmarks().then((res) => setBookmarkCount(res.posts.length)).catch(() => setBookmarkCount(0)); }, [user]); if (!user) return null; @@ -85,6 +89,18 @@ export default function MeOverviewPage() { hint="来自其他成员对你联系方式的查看申请" href={toQueryRoute("/me/contact-requests")} /> + + +
+

个人编辑与管理

+
+ + + + +
+
+

即将到来的活动

diff --git a/src/app/(app)/me/profile/page.tsx b/src/app/(app)/me/profile/page.tsx index fc1d62c..3ba17f8 100644 --- a/src/app/(app)/me/profile/page.tsx +++ b/src/app/(app)/me/profile/page.tsx @@ -4,9 +4,11 @@ import { api, type SessionUser } from "@/lib/api"; import { useAuth } from "@/contexts/AuthContext"; import { PageHeader } from "@/components/ui/page-header"; import { ProfileForm } from "./ProfileForm"; +import { ContactsManager } from "../contacts/ContactsManager"; export default function MeProfilePage() { const { user } = useAuth(); + const [contacts, setContacts] = React.useState([]); const [initial, setInitial] = React.useState<{ handle: string; displayName: string; @@ -29,16 +31,25 @@ export default function MeProfilePage() { }); }, []); + const loadContacts = React.useCallback(() => { + api.users.myContacts().then((res: { contacts: unknown[] }) => setContacts(res.contacts)); + }, []); + + React.useEffect(() => { + loadContacts(); + }, [loadContacts]); + if (!user || !initial) return null; return (
+
); } diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index 54de0c1..068094d 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -66,8 +66,131 @@ function notificationText(n: Notification): { title: string; href?: string } { return { title: eventTitle ? `有人向你推荐了「${eventTitle}」` : "有人向你推荐了一个活动", href: eventHref }; case "APP_APPROVED": return { title: "你的入站申请已通过,欢迎加入!", href: toQueryRoute("/events") }; + case "POST_APPROVED": { + const postId = strField("postId"); + const title = strField("title"); + return { + title: title ? `「${title}」已发布` : "你的帖子已发布", + href: postId ? toQueryRoute(`/posts/${postId}`) : toQueryRoute("/posts"), + }; + } + case "POST_PENDING_REVIEW": { + const postId = strField("postId"); + const title = strField("title"); + const reason = strField("reason"); + return { + title: title + ? `「${title}」正在等待人工复核${reason ? ` — ${reason}` : ""}` + : "你的帖子正在等待人工复核", + href: postId ? toQueryRoute(`/posts/${postId}`) : toQueryRoute("/posts"), + }; + } + case "POST_REJECTED": { + const postId = strField("postId"); + const title = strField("title"); + const reason = strField("reason"); + return { + title: title + ? `「${title}」未通过审核${reason ? ` — ${reason}` : ""}` + : "你的帖子未通过审核", + href: postId ? toQueryRoute(`/posts/${postId}`) : toQueryRoute("/posts"), + }; + } + case "XIAO_T_REPLIED": { + const postId = strField("postId"); + const title = strField("title"); + return { + title: title ? `小T 回复了「${title}」` : "小T 给你的帖子回复了", + href: postId ? toQueryRoute(`/posts/${postId}`) : toQueryRoute("/posts"), + }; + } + case "POST_BLOCKED_EVENT": { + const title = strField("title"); + return { + title: title + ? `「${title}」未发布:看似活动召集,需要 TRUSTED 及以上权限` + : "你的内容看似活动召集,未能发布(需要 TRUSTED 权限)", + }; + } + case "POST_RELOCATED_TO_EVENT": { + const eventSlug = strField("eventSlug"); + const title = strField("title"); + return { + title: title + ? `「${title}」已自动迁移到活动区` + : "你发的帖子已迁移到活动区", + href: eventSlug ? toQueryRoute(`/events/${eventSlug}`) : toQueryRoute("/events"), + }; + } + case "POST_NEEDS_EVENT_INFO": { + const postId = strField("postId"); + const title = strField("title"); + const missingRaw = p.missing; + const missing = Array.isArray(missingRaw) + ? (missingRaw as unknown[]).filter((x): x is string => typeof x === "string").join("、") + : ""; + return { + title: title + ? `「${title}」像是活动但信息不全${missing ? `:缺 ${missing}` : ""}` + : "你的帖子像是活动,但信息不全", + href: postId ? toQueryRoute(`/posts/${postId}`) : toQueryRoute("/posts"), + }; + } + case "REPORT_AUTO_RESOLVED": { + const targetType = strField("targetType"); + const reason = strField("reason"); + const targetId = strField("targetId"); + const href = + targetType === "POST" && targetId + ? toQueryRoute(`/posts/${targetId}`) + : undefined; + return { + title: `你的举报已自动处理:${reason ?? "目标已被隐藏"}`, + href, + }; + } + case "REPORT_AUTO_DISMISSED": { + const reason = strField("reason"); + return { + title: `你的举报被关闭:${reason ?? "AI 审核认为内容合规"}`, + }; + } + case "REPORT_RECEIVED": { + const reason = strField("reason"); + return { + title: `举报已收到,已交人工复核${reason ? `(${reason})` : ""}`, + }; + } + case "CONTENT_HIDDEN_BY_REPORT": { + const reason = strField("reason"); + const targetType = strField("targetType"); + return { + title: `你的${targetType === "EVENT" ? "活动" : targetType === "COMMENT" ? "评论" : "帖子"}因举报被隐藏:${reason ?? "审核认定违规"}`, + }; + } + case "EVENT_APPROVED": + return { title: eventTitle ? `「${eventTitle}」已发布` : "活动已发布", href: eventHref }; + case "EVENT_PENDING_REVIEW": + return { + title: eventTitle ? `「${eventTitle}」等待复核` : "活动等待复核", + href: eventHref, + }; + case "EVENT_REJECTED": { + const reason = strField("reason"); + return { + title: eventTitle + ? `「${eventTitle}」未通过审核${reason ? ` — ${reason}` : ""}` + : "活动未通过审核", + href: eventHref, + }; + } default: - return { title: n.kind }; + // Unknown kind — surface as a soft "system message" rather than the + // raw SHOUTY_SNAKE_CASE identifier. Log so we notice in dev. + if (typeof console !== "undefined") { + console.warn("Unhandled notification kind:", n.kind, p); + } + return { title: "你收到一条系统通知" }; } } diff --git a/src/app/(app)/posts/EditPostPageClient.tsx b/src/app/(app)/posts/EditPostPageClient.tsx new file mode 100644 index 0000000..3eecae8 --- /dev/null +++ b/src/app/(app)/posts/EditPostPageClient.tsx @@ -0,0 +1,87 @@ +"use client"; + +import * as React from "react"; +import { Suspense } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { api, type Post } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { canEditPost } from "@/lib/access"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty"; +import { PostForm } from "@/components/post/PostForm"; +import { getQueryRoute, toQueryRoute } from "@/lib/query-routing"; + +function EditPostInner() { + const params = useParams<{ id: string }>(); + const searchParams = useSearchParams(); + const queryRoutePath = React.useMemo(() => getQueryRoute(searchParams).path, [searchParams]); + const queryId = React.useMemo(() => { + const match = queryRoutePath.match(/^\/posts\/([^/]+)\/edit$/); + return match?.[1]; + }, [queryRoutePath]); + const id = params?.id ?? queryId; + const { user, loading } = useAuth(); + const router = useRouter(); + const [post, setPost] = React.useState(null); + const [notFound, setNotFound] = React.useState(false); + + React.useEffect(() => { + if (!id) return; + api.posts + .get(id) + .then((res) => setPost(res.post)) + .catch(() => setNotFound(true)); + }, [id]); + + if (loading) return null; + if (!user) { + router.replace(toQueryRoute(`/sign-in?callbackUrl=/posts/${id}/edit`)); + return null; + } + if (notFound) { + return ( + + ); + } + if (!post || !id) return null; + + if (!canEditPost(user, post)) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} + +export default function EditPostPageClient() { + return ( + + + + ); +} diff --git a/src/app/(app)/posts/PostDetailPageClient.tsx b/src/app/(app)/posts/PostDetailPageClient.tsx new file mode 100644 index 0000000..96ad0ee --- /dev/null +++ b/src/app/(app)/posts/PostDetailPageClient.tsx @@ -0,0 +1,282 @@ +"use client"; + +import * as React from "react"; +import { Suspense } from "react"; +import Link from "next/link"; +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"; +import { Button } from "@/components/ui/button"; +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 { relativeTime } from "@/lib/utils"; +import { getQueryRoute, toQueryRoute } from "@/lib/query-routing"; + +function PostDetailInner() { + const params = useParams<{ id: string }>(); + const searchParams = useSearchParams(); + const queryRoutePath = React.useMemo(() => getQueryRoute(searchParams).path, [searchParams]); + const queryId = React.useMemo(() => { + const match = queryRoutePath.match(/^\/posts\/([^/]+)$/); + return match?.[1]; + }, [queryRoutePath]); + const id = params?.id ?? queryId; + const { user } = useAuth(); + const router = useRouter(); + const { toast } = useToast(); + const [post, setPost] = React.useState(null); + const [notFound, setNotFound] = React.useState(false); + const likePending = React.useRef(false); + const bookmarkPending = React.useRef(false); + const subPending = React.useRef(false); + + React.useEffect(() => { + if (!id) return; + api.posts + .get(id) + .then((res) => setPost(res.post)) + .catch(() => setNotFound(true)); + }, [id]); + + if (notFound) { + return ( +
+

帖子不存在或无权访问。

+
+ ); + } + if (!post || !id) return null; + + const isAuthor = !!user && user.id === post.author_id; + const isAdmin = user?.tier === "ADMIN"; + const pending = post.status === "PENDING_REVIEW"; + 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; + if (!confirm("确认删除这条帖子?")) return; + try { + await api.posts.remove(id); + toast({ title: "已删除", variant: "success" }); + router.push(toQueryRoute("/posts")); + } catch (err) { + const msg = err instanceof Error ? err.message : "删除失败"; + toast({ title: "删除失败", description: msg, variant: "danger" }); + } + } + + async function toggleLike() { + if (!id || !user || likePending.current) return; + likePending.current = true; + const before = !!post?.liked; + setPost((p) => (p ? { ...p, liked: !before, likeCount: (p.likeCount ?? 0) + (before ? -1 : 1) } : p)); + try { + const res = await api.posts.toggleLike(id); + setPost((p) => (p ? { ...p, liked: res.liked } : p)); + } catch { + setPost((p) => (p ? { ...p, liked: before, likeCount: (p.likeCount ?? 0) + (before ? 1 : -1) } : p)); + } finally { + likePending.current = false; + } + } + + async function toggleBookmark() { + if (!id || !user || bookmarkPending.current) return; + bookmarkPending.current = true; + const before = !!post?.bookmarked; + setPost((p) => (p ? { ...p, bookmarked: !before } : p)); + try { + const res = await api.posts.toggleBookmark(id); + setPost((p) => (p ? { ...p, bookmarked: res.bookmarked } : p)); + toast({ title: res.bookmarked ? "已收藏" : "已取消收藏", variant: "default" }); + } catch { + setPost((p) => (p ? { ...p, bookmarked: before } : p)); + } finally { + bookmarkPending.current = false; + } + } + + async function toggleSubscribe() { + if (!post || !user || subPending.current) return; + subPending.current = true; + const before = !!post.subscribed; + setPost((p) => (p ? { ...p, subscribed: !before } : p)); + try { + if (before) { + await api.subscriptions.unfollow("AUTHOR", post.author_id); + toast({ title: `已取消关注 ${post.author_name}`, variant: "default" }); + } else { + await api.subscriptions.follow("AUTHOR", post.author_id); + toast({ title: `已关注 ${post.author_name}`, variant: "success" }); + } + } catch { + setPost((p) => (p ? { ...p, subscribed: before } : p)); + } finally { + subPending.current = false; + } + } + + return ( +
+
+ + 返回广场 + +
+ + {pending && (isAuthor || isAdmin) ? ( + + +

+ 等待人工复核 + {" — "}内容暂时仅你和管理员可见。具体进度可在 + 通知页面 + 查看。 +

+
+
+ ) : null} + + {rejected && (isAuthor || isAdmin) ? ( + + +

+ 未通过审核。 + 查看通知详情。 +

+
+
+ ) : null} + + {hidden && (isAuthor || isAdmin) ? ( + + +

该帖已被管理员或 AI 审核隐藏。

+
+
+ ) : null} + + {draft && isAuthor ? ( + + +

+ 草稿 — 只有你能看到。 + 继续编辑 +

+
+
+ ) : null} + +
+
+ {tags.length > 0 ? ( + tags.map((t) => ( + + {TAG_LABEL[t] ?? t} + + )) + ) : ( + 动态 + )} + {post.section === "MEDICAL" && post.hospital ? ( + {post.hospital} + ) : null} +
+

{post.title}

+
+ + {post.author_name} + + · {relativeTime(new Date(post.created_at))} + {user && !isAuthor ? ( + + ) : null} +
+
+ + {post.body} + +
+ +
+ {user ? ( + <> + + + + ) : null} +
+ {!isAuthor && user ? ( + + ) : null} + {(isAuthor || isAdmin) ? ( + <> + + + + ) : null} +
+
+ + {post.status === "PUBLISHED" ? : null} +
+ ); +} + +export default function PostDetailPageClient() { + return ( + + + + ); +} diff --git a/src/app/(app)/posts/new/page.tsx b/src/app/(app)/posts/new/page.tsx new file mode 100644 index 0000000..96e803c --- /dev/null +++ b/src/app/(app)/posts/new/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Suspense } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/contexts/AuthContext"; +import { canCreatePost } from "@/lib/access"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty"; +import { PostForm } from "@/components/post/PostForm"; +import { toQueryRoute } from "@/lib/query-routing"; + +function NewPostInner() { + const { user, loading } = useAuth(); + const router = useRouter(); + + if (loading) return null; + if (!user) { + router.replace(toQueryRoute("/sign-in?callbackUrl=/posts/new")); + return null; + } + if (!canCreatePost(user)) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} + +export default function NewPostPage() { + return ( + + + + ); +} diff --git a/src/app/(app)/posts/page.tsx b/src/app/(app)/posts/page.tsx new file mode 100644 index 0000000..293904b --- /dev/null +++ b/src/app/(app)/posts/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Plus, Search } from "lucide-react"; +import { api, type Post } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { canCreatePost } from "@/lib/access"; +import { PageHeader } from "@/components/ui/page-header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { EmptyState } from "@/components/ui/empty"; +import { PostCard } from "@/components/post/PostCard"; +import { TAG_TABS, parsePostTags } from "@/lib/post-tags"; +import { getQueryRoute, toQueryRoute } from "@/lib/query-routing"; + +const PAGE_SIZE = 30; + +function PostsListInner() { + const { user } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const queryRoute = React.useMemo(() => getQueryRoute(searchParams), [searchParams]); + const isLiteralPostsRoute = queryRoute.path === "/posts" || queryRoute.path.startsWith("/posts/"); + const routeParams = isLiteralPostsRoute ? queryRoute.params : searchParams; + const activeTab = routeParams.get("tab") ?? "all"; + const initialQ = routeParams.get("q") ?? ""; + + const [searchText, setSearchText] = React.useState(initialQ); + const [posts, setPosts] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [loadFailed, setLoadFailed] = React.useState(false); + + const tabConfig = React.useMemo( + () => TAG_TABS.find((t) => t.key === activeTab), + [activeTab], + ); + + const baseParams = React.useMemo(() => { + const p: { tag?: string; q?: string; page?: number } = {}; + if (initialQ) p.q = initialQ; + if (tabConfig?.tag && !tabConfig.tag.endsWith("-")) p.tag = tabConfig.tag; + return p; + }, [initialQ, tabConfig]); + + // Initial / filter-change load. + React.useEffect(() => { + let cancelled = false; + setLoading(true); + api.posts + .list({ ...baseParams, page: 1 }) + .then((res) => { + if (cancelled) return; + let list = res.posts; + if (tabConfig?.tag && tabConfig.tag.endsWith("-")) { + const prefix = tabConfig.tag; + list = list.filter((p) => parsePostTags(p.tags).some((t) => t.startsWith(prefix))); + } + setPosts(list); + setHasMore(!!res.hasMore); + setPage(1); + setLoadFailed(false); + }) + .catch(() => { + if (cancelled) return; + setPosts([]); + setHasMore(false); + setLoadFailed(true); + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [baseParams, tabConfig]); + + async function loadMore() { + if (!hasMore || loading) return; + setLoading(true); + try { + const res = await api.posts.list({ ...baseParams, page: page + 1 }); + let list = res.posts; + if (tabConfig?.tag && tabConfig.tag.endsWith("-")) { + const prefix = tabConfig.tag; + list = list.filter((p) => parsePostTags(p.tags).some((t) => t.startsWith(prefix))); + } + setPosts((prev) => [...prev, ...list]); + setHasMore(!!res.hasMore); + setPage(page + 1); + } finally { + setLoading(false); + } + } + + function onSearch(e: React.FormEvent) { + e.preventDefault(); + const params = new URLSearchParams(); + if (activeTab !== "all") params.set("tab", activeTab); + if (searchText.trim()) params.set("q", searchText.trim()); + const qs = params.toString(); + router.push(toQueryRoute(qs ? `/posts?${qs}` : "/posts")); + } + + return ( +
+ + + 发帖 + + + ) : null + } + /> + +
+
+ + setSearchText(e.target.value)} + placeholder="搜索标题或正文…" + className="pl-8" + /> +
+ + {initialQ ? ( + + ) : null} +
+ +
+ {TAG_TABS.map((tab) => { + const params = new URLSearchParams(); + if (tab.key !== "all") params.set("tab", tab.key); + if (initialQ) params.set("q", initialQ); + const qs = params.toString(); + return ( + + {tab.label} + + ); + })} +
+ + {posts.length === 0 ? ( + + 发个帖 + + ) : null + } + /> + ) : ( + <> +
+ {posts.map((p) => ( + + ))} +
+ {hasMore ? ( +
+ +
+ ) : null} + + )} +
+ ); +} + +export default function PostsPage() { + return ( + + + + ); +} + +function FilterPill({ + href, + active, + children, +}: { + href: string; + active: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/(app)/u/page.tsx b/src/app/(app)/u/page.tsx index 891467a..bf1f3bc 100644 --- a/src/app/(app)/u/page.tsx +++ b/src/app/(app)/u/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import UserProfilePageClient from "./[handle]/UserProfilePageClient"; export default function UserProfileStaticPage() { diff --git a/src/app/(auth)/apply/page.tsx b/src/app/(auth)/apply/page.tsx index 3df0d6d..1eee2aa 100644 --- a/src/app/(auth)/apply/page.tsx +++ b/src/app/(auth)/apply/page.tsx @@ -1,6 +1,6 @@ -import ApplyPageClient from "./ApplyPageClient"; +"use client"; -export const metadata = { title: "入站申请" }; +import ApplyPageClient from "./ApplyPageClient"; export default function ApplyPage() { return ; diff --git a/src/app/(auth)/apply/pending/page.tsx b/src/app/(auth)/apply/pending/page.tsx index 3e2de86..f4a8ea6 100644 --- a/src/app/(auth)/apply/pending/page.tsx +++ b/src/app/(auth)/apply/pending/page.tsx @@ -1,6 +1,6 @@ -import ApplyPendingPageClient from "./ApplyPendingPageClient"; +"use client"; -export const metadata = { title: "申请已提交" }; +import ApplyPendingPageClient from "./ApplyPendingPageClient"; export default function ApplyPendingPage() { return ; diff --git a/src/app/(auth)/sign-in/check-email/page.tsx b/src/app/(auth)/sign-in/check-email/page.tsx index 6e9ac0c..40240c8 100644 --- a/src/app/(auth)/sign-in/check-email/page.tsx +++ b/src/app/(auth)/sign-in/check-email/page.tsx @@ -1,6 +1,6 @@ -import CheckEmailPageClient from "./CheckEmailPageClient"; +"use client"; -export const metadata = { title: "查收邮件" }; +import CheckEmailPageClient from "./CheckEmailPageClient"; export default function CheckEmailPage() { return ; diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 507e7ec..5b69909 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,6 +1,6 @@ -import SignInPageClient from "./SignInPageClient"; +"use client"; -export const metadata = { title: "登录" }; +import SignInPageClient from "./SignInPageClient"; export default function SignInPage() { return ; diff --git a/src/app/(auth)/sign-in/verify/page.tsx b/src/app/(auth)/sign-in/verify/page.tsx index f3896e3..a4db7d1 100644 --- a/src/app/(auth)/sign-in/verify/page.tsx +++ b/src/app/(auth)/sign-in/verify/page.tsx @@ -1,6 +1,6 @@ -import VerifySignInPageClient from "./VerifySignInPageClient"; +"use client"; -export const metadata = { title: "验证登录链接" }; +import VerifySignInPageClient from "./VerifySignInPageClient"; export default function VerifySignInPage() { return ; diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 2ea923a..db143cf 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,6 +1,6 @@ -import SignUpPageClient from "./SignUpPageClient"; +"use client"; -export const metadata = { title: "加入社群" }; +import SignUpPageClient from "./SignUpPageClient"; export default function SignUpPage() { return ; diff --git a/src/app/(marketing)/about/page.tsx b/src/app/(marketing)/about/page.tsx index 955d4be..4186d2e 100644 --- a/src/app/(marketing)/about/page.tsx +++ b/src/app/(marketing)/about/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import AboutPageClient from "./AboutPageClient"; export default function AboutPage() { diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx index eb2b84a..782b601 100644 --- a/src/app/(marketing)/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -24,9 +24,16 @@ import MeContactRequestsPage from "../(app)/me/contact-requests/page"; import MeRegistrationsPage from "../(app)/me/registrations/page"; import MeInvitesPage from "../(app)/me/invites/page"; import MeBlocksPage from "../(app)/me/blocks/page"; +import MeDraftsPage from "../(app)/me/drafts/page"; +import MeBookmarksPage from "../(app)/me/bookmarks/page"; import NotificationsPage from "../(app)/notifications/page"; +import PostsPage from "../(app)/posts/page"; +import NewPostPage from "../(app)/posts/new/page"; +import PostDetailPageClient from "../(app)/posts/PostDetailPageClient"; +import EditPostPageClient from "../(app)/posts/EditPostPageClient"; import AdminLayout from "../admin/layout"; import AdminApplicationsPage from "../admin/applications/page"; +import AdminPostsPage from "../admin/posts/page"; import AdminReportsPage from "../admin/reports/page"; import AdminUsersPage from "../admin/users/page"; import AdminAuditPage from "../admin/audit/page"; @@ -61,6 +68,10 @@ function HomePageInner() { const isLegacyEventsManage = queryRoutePath === "/events/manage"; const isLegacyEventsRegister = queryRoutePath === "/events/register"; const isEventsNew = queryRoutePath === "/events/new"; + const isPostsIndex = queryRoutePath === "/posts"; + const isPostNew = queryRoutePath === "/posts/new"; + const isPostEdit = /^\/posts\/[^/]+\/edit$/.test(queryRoutePath); + const isPostDetail = /^\/posts\/[^/]+$/.test(queryRoutePath); const isLegacyUserDetail = queryRoutePath === "/u"; const isMeIndex = queryRoutePath === "/me"; const isMeProfile = queryRoutePath === "/me/profile"; @@ -70,9 +81,12 @@ function HomePageInner() { const isMeRegistrations = queryRoutePath === "/me/registrations"; const isMeInvites = queryRoutePath === "/me/invites"; const isMeBlocks = queryRoutePath === "/me/blocks"; + const isMeDrafts = queryRoutePath === "/me/drafts"; + const isMeBookmarks = queryRoutePath === "/me/bookmarks"; const isNotifications = queryRoutePath === "/notifications"; const isAdminIndex = queryRoutePath === "/admin"; const isAdminApplications = queryRoutePath === "/admin/applications"; + const isAdminPosts = queryRoutePath === "/admin/posts"; const isAdminReports = queryRoutePath === "/admin/reports"; const isAdminUsers = queryRoutePath === "/admin/users"; const isAdminAudit = queryRoutePath === "/admin/audit"; @@ -123,6 +137,10 @@ function HomePageInner() { } if (isEventsIndex) return ; if (isEventsNew) return ; + if (isPostsIndex) return ; + if (isPostNew) return ; + if (isPostEdit) return ; + if (isPostDetail) return ; if (isLegacyEventsManage) return ; if (isLegacyEventsEdit) return ; if (isLegacyEventsRegister) return ; @@ -138,6 +156,8 @@ function HomePageInner() { if (isMeRegistrations) return ; if (isMeInvites) return ; if (isMeBlocks) return ; + if (isMeDrafts) return ; + if (isMeBookmarks) return ; if (isNotifications) return ; if (isAdminIndex || isAdminApplications) { return ( @@ -146,6 +166,13 @@ function HomePageInner() { ); } + if (isAdminPosts) { + return ( + + + + ); + } if (isAdminReports) { return ( diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 255d9e2..23b651b 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -27,6 +27,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) 管理后台
申请审核 + 帖子复核 举报队列 用户管理 操作日志 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a131f0b..e374c27 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,5 +1,15 @@ -import { redirect } from "next/navigation"; +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { toQueryRoute } from "@/lib/query-routing"; export default function AdminIndex() { - redirect("/admin/applications"); + const router = useRouter(); + + useEffect(() => { + router.replace(toQueryRoute("/admin/applications")); + }, [router]); + + return null; } diff --git a/src/app/admin/posts/page.tsx b/src/app/admin/posts/page.tsx new file mode 100644 index 0000000..bbfd72f --- /dev/null +++ b/src/app/admin/posts/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { api, type Post } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { EmptyState } from "@/components/ui/empty"; +import { useToast } from "@/components/ui/toast-context"; +import { PageHeader } from "@/components/ui/page-header"; +import { POST_SECTION_LABEL, type PostSection } from "@/lib/enums"; +import { toQueryRoute } from "@/lib/query-routing"; +import { relativeTime } from "@/lib/utils"; + +export default function AdminPostsPage() { + const { toast } = useToast(); + const [posts, setPosts] = React.useState([]); + const [loading, setLoading] = React.useState(true); + + const refresh = React.useCallback(() => { + setLoading(true); + api.admin + .listPendingPosts() + .then((res) => setPosts(res.posts)) + .catch(() => setPosts([])) + .finally(() => setLoading(false)); + }, []); + + React.useEffect(() => { + refresh(); + }, [refresh]); + + async function decide(id: string, decision: "APPROVE" | "REJECT" | "HIDE") { + try { + await api.admin.reviewPost(id, decision); + toast({ + title: + decision === "APPROVE" ? "已放行" : decision === "REJECT" ? "已拒绝" : "已隐藏", + variant: "success", + }); + refresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : "操作失败"; + toast({ title: "操作失败", description: msg, variant: "danger" }); + } + } + + return ( +
+ + + {loading ? ( +

加载中…

+ ) : posts.length === 0 ? ( + + ) : ( +
+ {posts.map((p) => ( + + +
+ {POST_SECTION_LABEL[p.section as PostSection]} + PENDING_REVIEW + + {p.author_name} · {relativeTime(new Date(p.created_at))} + +
+ + {p.title} + +

{p.body}

+ {p.moderation_reason ? ( +
+ AI 判断:{p.moderation_verdict} ·{" "} + {p.moderation_reason} + {p.moderation_categories && p.moderation_categories !== "[]" ? ( + <> · 标签:{p.moderation_categories} + ) : null} +
+ ) : null} +
+ + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/event/EventForm.tsx b/src/components/event/EventForm.tsx index 10a2e8a..6fc62e4 100644 --- a/src/components/event/EventForm.tsx +++ b/src/components/event/EventForm.tsx @@ -26,6 +26,7 @@ import { eventInputSchema, type EventInput } from "@/lib/validators/event"; import { api } from "@/lib/api"; import { Plus, Trash2 } from "lucide-react"; import { toQueryRoute } from "@/lib/query-routing"; +import { useAuth } from "@/contexts/AuthContext"; type Question = { id: string; @@ -98,6 +99,7 @@ type InitialState = Partial< registrationOpensAt?: Date | string | null; registrationClosesAt?: Date | string | null; visibility?: "PUBLIC" | "VERIFIED" | "TRUSTED"; + status?: string; customQuestions?: Question[]; }; @@ -112,7 +114,10 @@ export function EventForm({ }) { const router = useRouter(); const { toast } = useToast(); + const { user } = useAuth(); const [pending, setPending] = React.useState(false); + const isDraftEdit = mode === "edit" && initial?.status === "DRAFT"; + const isAdmin = user?.tier === "ADMIN"; const [form, setForm] = React.useState(() => ({ ...DEFAULT, ...(initial as Partial), @@ -128,7 +133,11 @@ export function EventForm({ setForm((f) => ({ ...f, [key]: value })); } - async function onSubmit(e: React.FormEvent) { + async function onSubmit( + e: React.FormEvent, + asDraft = false, + adminStatus?: "DRAFT" | "PUBLISHED", + ) { e.preventDefault(); const payload: EventInput = { title: form.title.trim(), @@ -165,13 +174,24 @@ export function EventForm({ setPending(true); const res = mode === "create" - ? await api.events.create(parsed.data) - : await api.events.update(eventId!, parsed.data); + ? await api.events.create(parsed.data, asDraft) + : await api.events.update( + eventId!, + adminStatus ? { ...parsed.data, adminStatus } : parsed.data, + asDraft, + ); setPending(false); if (res.ok) { - toast({ title: mode === "create" ? "已发布" : "已保存", variant: "success" }); + toast({ + title: asDraft ? "已保存草稿" : mode === "create" ? "已发布" : "已保存", + variant: "success", + }); const slug = (res as { slug?: string }).slug; - router.push(slug ? toQueryRoute(`/events/${slug}`) : toQueryRoute("/events")); + if (asDraft) { + router.push(slug ? toQueryRoute(`/events/${slug}/edit`) : toQueryRoute("/events")); + } else { + router.push(slug ? toQueryRoute(`/events/${slug}`) : toQueryRoute("/events")); + } } else { toast({ title: "失败", variant: "danger" }); } @@ -207,7 +227,10 @@ export function EventForm({ const isHybrid = form.format === "HYBRID"; return ( -
+ onSubmit(e, false, isAdmin && isDraftEdit ? "PUBLISHED" : undefined)} + className="space-y-6" + >
@@ -503,8 +526,30 @@ export function EventForm({ + {mode === "create" || isDraftEdit ? ( + + ) : null} + {mode === "edit" && isAdmin && !isDraftEdit ? ( + + ) : null}
diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index e42459d..fb79c66 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Calendar, Home, User, Bell } from "lucide-react"; +import { Calendar, Home, User, Bell, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import { toQueryRoute } from "@/lib/query-routing"; @@ -11,28 +11,29 @@ export function MobileNav({ signedIn }: { signedIn: boolean }) { const items = [ { href: toQueryRoute("/events"), label: "活动", icon: Calendar, match: /^\/events/ }, + { href: toQueryRoute("/posts"), label: "广场", icon: Sparkles, match: /^\/posts/ }, { href: toQueryRoute("/me"), label: "我", icon: Home, match: /^\/me$/ }, { href: toQueryRoute("/notifications"), label: "通知", icon: Bell, match: /^\/notifications/ }, { href: toQueryRoute("/me/profile"), label: "主页", icon: User, match: /^\/me\/profile/ }, ]; return ( -