feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -315,7 +315,7 @@ export default function AvatarCropModal({
|
||||
type="button"
|
||||
onClick={() => handlePick(item)}
|
||||
disabled={busy || active}
|
||||
title={
|
||||
data-tip={
|
||||
active
|
||||
? "当前头像"
|
||||
: item.kind === "image"
|
||||
@@ -366,7 +366,7 @@ export default function AvatarCropModal({
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={confirming ? "再次点击确认删除" : "删除图片"}
|
||||
title={confirming ? "再次点击确认彻底删除" : "彻底删除"}
|
||||
data-tip={confirming ? "再次点击确认彻底删除" : "彻底删除"}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
style={{
|
||||
background: confirming ? "var(--red, #dc2626)" : "var(--panel)",
|
||||
|
||||
@@ -69,8 +69,8 @@ export default function AvatarPicker({
|
||||
onClick={() => setOpen(true)}
|
||||
className="group relative inline-flex items-center justify-center shrink-0 p-0 rounded-full cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--panel)]"
|
||||
style={{ width: size, height: size, lineHeight: 0 }}
|
||||
data-tip="更换头像"
|
||||
aria-label="更换头像"
|
||||
title="更换头像"
|
||||
>
|
||||
<Avatar name={name} src={current || undefined} size={size} />
|
||||
{/* hover/focus 遮罩:与头像严格重合(按钮宽高固定 = size) */}
|
||||
|
||||
192
frontend/components/BoardPicker.tsx
Normal file
192
frontend/components/BoardPicker.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import type { Board } from "@/lib/api";
|
||||
|
||||
type BoardPickerProps = {
|
||||
boards: Board[];
|
||||
value: number;
|
||||
onChange: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
function boardColorIndex(b: Board, fallbackIndex: number) {
|
||||
return Math.max(0, b.color_index ?? fallbackIndex) % 8;
|
||||
}
|
||||
|
||||
export default function BoardPicker({
|
||||
boards,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
id,
|
||||
}: BoardPickerProps) {
|
||||
const listId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const selectedIdx = boards.findIndex((b) => b.id === value);
|
||||
const selected = selectedIdx >= 0 ? boards[selectedIdx] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
|
||||
}, [open, selectedIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const pick = (id: number) => {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled || loading) return;
|
||||
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!boards.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % boards.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + boards.length) % boards.length);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const b = boards[activeIndex];
|
||||
if (b) pick(b.id);
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setActiveIndex(boards.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const label = loading
|
||||
? "加载板块…"
|
||||
: selected
|
||||
? selected.name
|
||||
: boards.length === 0
|
||||
? "暂无可用板块"
|
||||
: "选择板块";
|
||||
|
||||
const colorIdx = selected
|
||||
? boardColorIndex(selected, selectedIdx)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="j13-board-picker" ref={rootRef}>
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="j13-board-trigger"
|
||||
disabled={disabled || loading || boards.length === 0}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${colorIdx}`} aria-hidden>
|
||||
{(selected?.name || "板").slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-semibold text-[14px]">
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="shrink-0 opacity-60"
|
||||
style={{
|
||||
color: "var(--ink-3)",
|
||||
transform: open ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 0.15s ease",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label="选择板块"
|
||||
className="j13-board-menu"
|
||||
tabIndex={-1}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
>
|
||||
{boards.map((b, i) => {
|
||||
const selectedHere = b.id === value;
|
||||
const c = boardColorIndex(b, i);
|
||||
return (
|
||||
<li key={b.id} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedHere}
|
||||
className={`j13-board-option${
|
||||
selectedHere ? " is-selected" : ""
|
||||
}${i === activeIndex ? " is-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
onClick={() => pick(b.id)}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{b.name}</span>
|
||||
{selectedHere && <Check size={15} className="shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 编辑态只读板块徽章(与触发器同高) */
|
||||
export function BoardBadge({
|
||||
name,
|
||||
colorIndex = 0,
|
||||
}: {
|
||||
name: string;
|
||||
colorIndex?: number;
|
||||
}) {
|
||||
const c = Math.max(0, colorIndex) % 8;
|
||||
return (
|
||||
<div className="j13-board-badge">
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { apiCreateComment, apiDeleteComment, type CommentNode, type User } from "@/lib/api";
|
||||
import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, type CommentNode, type User } from "@/lib/api";
|
||||
import { canModerateBoard } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "./Avatar";
|
||||
@@ -15,6 +15,8 @@ interface CommentSectionProps {
|
||||
postId: string;
|
||||
/** 帖子所属板块:板块管理员仅可管理授权板块的评论 */
|
||||
boardId: number;
|
||||
/** 楼主 ID:不可采纳楼主自己的评论 */
|
||||
authorId: number;
|
||||
comments: CommentNode[];
|
||||
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
|
||||
totalComments: number; // 全部评论数(含回复)→ 头部徽标
|
||||
@@ -22,6 +24,9 @@ interface CommentSectionProps {
|
||||
size: number;
|
||||
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
|
||||
user: User | null;
|
||||
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
|
||||
acceptMode?: "bounty" | "question" | null;
|
||||
acceptedCommentId?: number;
|
||||
}
|
||||
|
||||
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
|
||||
@@ -35,12 +40,15 @@ function countReplies(node: CommentNode): number {
|
||||
export default function CommentSection({
|
||||
postId,
|
||||
boardId,
|
||||
authorId,
|
||||
comments,
|
||||
total,
|
||||
totalComments,
|
||||
page,
|
||||
size,
|
||||
user,
|
||||
acceptMode = null,
|
||||
acceptedCommentId = 0,
|
||||
}: CommentSectionProps) {
|
||||
const router = useRouter();
|
||||
const [content, setContent] = useState("");
|
||||
@@ -50,6 +58,7 @@ export default function CommentSection({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -133,11 +142,38 @@ export default function CommentSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (commentId: number, commentAuthorId: number) => {
|
||||
if (!acceptMode || !user || commentAuthorId === authorId) return;
|
||||
setAcceptingId(commentId);
|
||||
try {
|
||||
const res =
|
||||
acceptMode === "question"
|
||||
? await apiAcceptQuestion(postId, commentId)
|
||||
: await apiAcceptBounty(postId, commentId);
|
||||
if (res.post) {
|
||||
toast(acceptMode === "question" ? "已采纳为最佳答案" : "已采纳并发放悬赏", "ok");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "采纳失败");
|
||||
}
|
||||
} catch {
|
||||
toast("网络错误,采纳失败");
|
||||
} finally {
|
||||
setAcceptingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||
|
||||
const canDelete = (comment: CommentNode) =>
|
||||
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
|
||||
|
||||
const canAccept = (comment: CommentNode) =>
|
||||
!!acceptMode &&
|
||||
!!user &&
|
||||
comment.status === "published" &&
|
||||
comment.user.id !== authorId;
|
||||
|
||||
const openReply = (c: CommentNode) => {
|
||||
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
|
||||
setReplyContent("");
|
||||
@@ -247,11 +283,25 @@ export default function CommentSection({
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||
}`}
|
||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||
title="回复"
|
||||
data-tip="回复"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
{acceptedCommentId === c.id && (
|
||||
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||||
)}
|
||||
{canAccept(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--accent)] font-semibold"
|
||||
disabled={acceptingId === c.id}
|
||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||
>
|
||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
<button
|
||||
@@ -261,7 +311,7 @@ export default function CommentSection({
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
@@ -325,11 +375,25 @@ export default function CommentSection({
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||
}`}
|
||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||
title="回复"
|
||||
data-tip="回复"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
{acceptedCommentId === c.id && (
|
||||
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||||
)}
|
||||
{canAccept(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--accent)] font-semibold"
|
||||
disabled={acceptingId === c.id}
|
||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||
>
|
||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
<button
|
||||
@@ -339,7 +403,7 @@ export default function CommentSection({
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
|
||||
92
frontend/components/ContentLockGate.tsx
Normal file
92
frontend/components/ContentLockGate.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Lock } from "lucide-react";
|
||||
import { apiUnlockPost } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export default function ContentLockGate({
|
||||
postId,
|
||||
hint,
|
||||
access,
|
||||
points,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
hint?: string;
|
||||
access?: string;
|
||||
points?: number;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const unlock = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiUnlockPost(String(postId));
|
||||
if (res.post) {
|
||||
toast("已解锁全文", "ok");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "解锁失败", "error");
|
||||
}
|
||||
} catch {
|
||||
toast("解锁失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] px-6 py-10 text-center my-4"
|
||||
style={{
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--accent) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Lock size={28} className="mx-auto mb-3" style={{ color: "var(--accent)" }} />
|
||||
<p className="font-bold text-[16px] mb-1" style={{ color: "var(--ink)" }}>
|
||||
{hint || "正文暂不可见"}
|
||||
</p>
|
||||
<p className="meta text-[13px] mb-5">
|
||||
{access === "points"
|
||||
? `支付 ${points || 0} 积分后可阅读全文`
|
||||
: access === "reply"
|
||||
? "发表一条回复后即可阅读全文"
|
||||
: access === "login"
|
||||
? "登录后即可阅读全文"
|
||||
: null}
|
||||
</p>
|
||||
{access === "login" && !loggedIn && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
去登录
|
||||
</Link>
|
||||
)}
|
||||
{access === "points" && loggedIn && (
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void unlock()}>
|
||||
{busy ? "解锁中…" : `支付 ${points || 0} 积分解锁`}
|
||||
</button>
|
||||
)}
|
||||
{access === "points" && !loggedIn && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
登录后解锁
|
||||
</Link>
|
||||
)}
|
||||
{access === "reply" && (
|
||||
<a href="#comments" className="btn btn-primary">
|
||||
去回复
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,6 +281,7 @@ export default function Header({
|
||||
className={`${iconBtn} hidden lg:inline-flex`}
|
||||
style={iconBtnStyle}
|
||||
aria-label="切换主题"
|
||||
data-tip={theme === "dark" ? "切换浅色" : "切换深色"}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
@@ -289,6 +290,7 @@ export default function Header({
|
||||
href="/chat"
|
||||
prefetch={false}
|
||||
aria-label="消息"
|
||||
data-tip="消息"
|
||||
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||||
style={{
|
||||
color: pathname?.startsWith("/chat")
|
||||
|
||||
@@ -72,7 +72,8 @@ export default function LikeButton({
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
title={liked ? "取消点赞" : "点赞"}
|
||||
data-tip={liked ? "取消点赞" : "点赞"}
|
||||
aria-label={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
className={`j13-like inline-flex items-center justify-center font-semibold tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
>
|
||||
|
||||
20
frontend/components/MarkdownBody.tsx
Normal file
20
frontend/components/MarkdownBody.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
|
||||
type MarkdownBodyProps = {
|
||||
content: string;
|
||||
className?: string;
|
||||
components?: Components;
|
||||
};
|
||||
|
||||
/** 站点统一 Markdown 渲染:详情页 / 发帖预览共用 prose-content */
|
||||
export default function MarkdownBody({
|
||||
content,
|
||||
className = "prose-content",
|
||||
components,
|
||||
}: MarkdownBodyProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown components={components}>{content || ""}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/components/MarkdownEditor.tsx
Normal file
320
frontend/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Code,
|
||||
Quote,
|
||||
List,
|
||||
Heading2,
|
||||
Link as LinkIcon,
|
||||
Image as ImageIcon,
|
||||
Eye,
|
||||
Pencil,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { apiUploadPostImage } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
onSubmitShortcut?: () => void;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type WrapOpts = {
|
||||
before: string;
|
||||
after?: string;
|
||||
placeholder?: string;
|
||||
linePrefix?: string;
|
||||
};
|
||||
|
||||
function applyWrap(
|
||||
el: HTMLTextAreaElement,
|
||||
value: string,
|
||||
opts: WrapOpts
|
||||
): { next: string; selStart: number; selEnd: number } {
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const selected = value.slice(start, end);
|
||||
const after = opts.after ?? "";
|
||||
|
||||
if (opts.linePrefix) {
|
||||
const block = selected || opts.placeholder || "列表项";
|
||||
const lines = block.split("\n").map((l) =>
|
||||
l.startsWith(opts.linePrefix!) ? l : `${opts.linePrefix}${l}`
|
||||
);
|
||||
const inserted = lines.join("\n");
|
||||
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||
return {
|
||||
next,
|
||||
selStart: start,
|
||||
selEnd: start + inserted.length,
|
||||
};
|
||||
}
|
||||
|
||||
const inner = selected || opts.placeholder || "";
|
||||
const inserted = opts.before + inner + after;
|
||||
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||
const selStart = start + opts.before.length;
|
||||
const selEnd = selStart + inner.length;
|
||||
return { next, selStart, selEnd };
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 16,
|
||||
disabled,
|
||||
onSubmitShortcut,
|
||||
id,
|
||||
}: MarkdownEditorProps) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<"write" | "preview">("write");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setFullscreen(false);
|
||||
}
|
||||
};
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
const runWrap = useCallback(
|
||||
(opts: WrapOpts) => {
|
||||
const el = taRef.current;
|
||||
if (!el || disabled) return;
|
||||
const { next, selStart, selEnd } = applyWrap(el, value, opts);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(selStart, selEnd);
|
||||
});
|
||||
},
|
||||
[value, onChange, disabled]
|
||||
);
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(text: string) => {
|
||||
const el = taRef.current;
|
||||
if (!el) {
|
||||
onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||
const inserted = padBefore + text + padAfter;
|
||||
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||
onChange(next);
|
||||
const caret = start + inserted.length;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const handleImagePick = async (file: File | null) => {
|
||||
if (!file || disabled) return;
|
||||
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
|
||||
toast("仅支持 JPEG / PNG / WebP", "error");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast("图片不能超过 5MB", "error");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await apiUploadPostImage(file);
|
||||
if (!res.url) {
|
||||
toast(res.error || "上传失败", "error");
|
||||
return;
|
||||
}
|
||||
const alt = file.name.replace(/\.[^.]+$/, "") || "图片";
|
||||
insertAtCursor(``);
|
||||
setMode("write");
|
||||
toast("图片已插入", "ok");
|
||||
} catch {
|
||||
toast("上传失败", "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const tools: {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
}[] = [
|
||||
{
|
||||
key: "bold",
|
||||
label: "粗体",
|
||||
icon: <Bold size={15} />,
|
||||
onClick: () => runWrap({ before: "**", after: "**", placeholder: "粗体" }),
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
label: "斜体",
|
||||
icon: <Italic size={15} />,
|
||||
onClick: () => runWrap({ before: "*", after: "*", placeholder: "斜体" }),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "行内代码",
|
||||
icon: <Code size={15} />,
|
||||
onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }),
|
||||
},
|
||||
{
|
||||
key: "quote",
|
||||
label: "引用",
|
||||
icon: <Quote size={15} />,
|
||||
onClick: () =>
|
||||
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }),
|
||||
},
|
||||
{
|
||||
key: "list",
|
||||
label: "列表",
|
||||
icon: <List size={15} />,
|
||||
onClick: () =>
|
||||
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }),
|
||||
},
|
||||
{
|
||||
key: "h2",
|
||||
label: "二级标题",
|
||||
icon: <Heading2 size={15} />,
|
||||
onClick: () => runWrap({ before: "## ", after: "", placeholder: "标题" }),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: "链接",
|
||||
icon: <LinkIcon size={15} />,
|
||||
onClick: () =>
|
||||
runWrap({ before: "[", after: "](https://)", placeholder: "链接文字" }),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: uploading ? "上传中…" : "插入图片",
|
||||
icon: <ImageIcon size={15} />,
|
||||
onClick: () => fileRef.current?.click(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}`}>
|
||||
<div className="j13-md-toolbar">
|
||||
<div className="flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
|
||||
{tools.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
data-tip={t.label}
|
||||
aria-label={t.label}
|
||||
disabled={disabled || (t.key === "image" && uploading)}
|
||||
onClick={t.onClick}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "write"}
|
||||
className={mode === "write" ? "is-on" : ""}
|
||||
onClick={() => setMode("write")}
|
||||
>
|
||||
<Pencil size={12} /> 编写
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "preview"}
|
||||
className={mode === "preview" ? "is-on" : ""}
|
||||
onClick={() => setMode("preview")}
|
||||
>
|
||||
<Eye size={12} /> 预览
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
|
||||
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
|
||||
onClick={() => setFullscreen((v) => !v)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImagePick(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
{mode === "write" ? (
|
||||
<textarea
|
||||
ref={taRef}
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
className="j13-md-textarea"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmitShortcut?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="j13-md-preview">
|
||||
{value.trim() ? (
|
||||
<MarkdownBody content={value} />
|
||||
) : (
|
||||
<p className="meta text-center py-16">暂无内容可预览</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="j13-md-foot">
|
||||
<span>Markdown · Ctrl/⌘ + Enter 提交</span>
|
||||
<span className="tabular-nums">{value.length} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
aria-label="通知"
|
||||
data-tip="通知"
|
||||
className="relative w-10 h-10 flex items-center justify-center rounded-full transition-colors"
|
||||
style={{
|
||||
color: open ? "var(--accent)" : "var(--ink-2)",
|
||||
|
||||
66
frontend/components/PostAttachments.tsx
Normal file
66
frontend/components/PostAttachments.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { postAttachmentDownloadURL, type PostAttachment } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function PostAttachments({
|
||||
postId,
|
||||
items,
|
||||
}: {
|
||||
postId: number;
|
||||
items: PostAttachment[];
|
||||
}) {
|
||||
if (!items.length) return null;
|
||||
|
||||
const onClick = (a: PostAttachment) => {
|
||||
if (a.price_points > 0 && !a.unlocked) {
|
||||
// 仍走下载 URL:后端会扣积分或 402
|
||||
toast(`将消耗 ${a.price_points} 积分下载`, "ok");
|
||||
}
|
||||
window.location.href = postAttachmentDownloadURL(postId, a.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-8 pt-6" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<h2 className="font-extrabold text-[15px] mb-3" style={{ color: "var(--ink)" }}>
|
||||
附件
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{items.map((a) => (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(a)}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-3 rounded-[12px] text-left transition-colors hover:bg-[var(--accent-soft)]"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||
>
|
||||
<Download size={16} style={{ color: "var(--accent)" }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{a.name}
|
||||
</div>
|
||||
<div className="meta text-[12px]">
|
||||
{formatSize(a.size)}
|
||||
{a.price_points > 0
|
||||
? a.unlocked
|
||||
? " · 已解锁"
|
||||
: ` · ${a.price_points} 积分`
|
||||
: " · 免费"}
|
||||
{a.download_count > 0 ? ` · ${a.download_count} 次下载` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="btn btn-line btn-sm shrink-0">下载</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
957
frontend/components/PostComposer.tsx
Normal file
957
frontend/components/PostComposer.tsx
Normal file
@@ -0,0 +1,957 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, Paperclip, Download } from "lucide-react";
|
||||
import {
|
||||
apiCreatePost,
|
||||
apiUpdatePost,
|
||||
apiUploadPostFile,
|
||||
apiDeletePostFile,
|
||||
apiUpdatePostFilePrice,
|
||||
fetchBoards,
|
||||
type Board,
|
||||
type PostAttachment,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
POST_TYPES,
|
||||
CONTENT_ACCESS,
|
||||
BOUNTY_EXPIRE_DAYS,
|
||||
LOTTERY_EXPIRE_DAYS,
|
||||
normalizePostType,
|
||||
type PostTypeValue,
|
||||
type ContentAccessValue,
|
||||
} from "@/lib/postMeta";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
|
||||
|
||||
const TITLE_MAX = 256;
|
||||
const DRAFT_KEY = "j13-compose-draft";
|
||||
const DRAFT_DEBOUNCE_MS = 600;
|
||||
|
||||
type ComposeDraft = {
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
boardId: number;
|
||||
postType?: string;
|
||||
contentAccess?: string;
|
||||
accessPoints?: number;
|
||||
typeMeta?: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[,,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
].slice(0, 12);
|
||||
}
|
||||
|
||||
function serializeTags(tags: string[]): string {
|
||||
return tags.join(", ");
|
||||
}
|
||||
|
||||
function mapPostError(msg: string): string {
|
||||
if (!msg) return "操作失败";
|
||||
if (msg.includes("24 小时") || msg.includes("24小时") || msg.includes("冷静")) {
|
||||
return "新用户注册满 24 小时后才能发帖,请稍后再试";
|
||||
}
|
||||
if (msg.includes("积分")) return msg;
|
||||
if (msg.includes("rate") || msg.includes("频繁") || msg.includes("太快")) {
|
||||
return "操作过于频繁,请稍后再试";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function readDraft(): ComposeDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw) as ComposeDraft;
|
||||
if (!d || typeof d !== "object") return null;
|
||||
return d;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(d: ComposeDraft) {
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(d));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
type CreateProps = {
|
||||
mode: "create";
|
||||
initialBoardId?: number;
|
||||
cancelHref?: string;
|
||||
};
|
||||
|
||||
type EditProps = {
|
||||
mode: "edit";
|
||||
postId: number;
|
||||
initialTitle: string;
|
||||
initialContent: string;
|
||||
initialTags?: string;
|
||||
initialPostType?: string;
|
||||
initialContentAccess?: string;
|
||||
initialAccessPoints?: number;
|
||||
initialTypeMeta?: string;
|
||||
initialAttachments?: PostAttachment[];
|
||||
/** 投票帖:有票后禁止改选项 */
|
||||
canEditPollOptions?: boolean;
|
||||
boardName?: string;
|
||||
boardId?: number;
|
||||
boardColorIndex?: number;
|
||||
cancelHref: string;
|
||||
};
|
||||
|
||||
export type PostComposerProps = CreateProps | EditProps;
|
||||
|
||||
export default function PostComposer(props: PostComposerProps) {
|
||||
const router = useRouter();
|
||||
const isCreate = props.mode === "create";
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boardsReady, setBoardsReady] = useState(!isCreate);
|
||||
const [boardId, setBoardId] = useState(
|
||||
isCreate ? props.initialBoardId || 0 : props.boardId || 0
|
||||
);
|
||||
const [title, setTitle] = useState(isCreate ? "" : props.initialTitle);
|
||||
const [content, setContent] = useState(isCreate ? "" : props.initialContent);
|
||||
const [tagList, setTagList] = useState<string[]>(
|
||||
parseTags(isCreate ? "" : props.initialTags || "")
|
||||
);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [postType, setPostType] = useState<PostTypeValue>(
|
||||
normalizePostType(isCreate ? "discussion" : props.initialPostType)
|
||||
);
|
||||
const [contentAccess, setContentAccess] = useState<ContentAccessValue>(
|
||||
(isCreate
|
||||
? "public"
|
||||
: (props.initialContentAccess as ContentAccessValue) || "public") as ContentAccessValue
|
||||
);
|
||||
const [accessPoints, setAccessPoints] = useState(
|
||||
isCreate ? 10 : props.initialAccessPoints || 10
|
||||
);
|
||||
const [typeMeta, setTypeMeta] = useState(
|
||||
isCreate ? "" : props.initialTypeMeta || ""
|
||||
);
|
||||
// 类型专属表单状态
|
||||
const [pollOptions, setPollOptions] = useState<string[]>(() => {
|
||||
try {
|
||||
const m = JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}");
|
||||
return Array.isArray(m.options) && m.options.length >= 2
|
||||
? m.options
|
||||
: ["", ""];
|
||||
} catch {
|
||||
return ["", ""];
|
||||
}
|
||||
});
|
||||
const [pollMulti, setPollMulti] = useState(() => {
|
||||
try {
|
||||
return !!JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").multi;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [pollAnonymous, setPollAnonymous] = useState(() => {
|
||||
if (isCreate) return true;
|
||||
try {
|
||||
const m = JSON.parse(props.initialTypeMeta || "{}");
|
||||
// 缺省视为匿名(与后端一致)
|
||||
return m.anonymous !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const [bountyPoints, setBountyPoints] = useState(() => {
|
||||
try {
|
||||
const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points);
|
||||
return p > 0 ? p : 20;
|
||||
} catch {
|
||||
return 20;
|
||||
}
|
||||
});
|
||||
const [lotterySlots, setLotterySlots] = useState(() => {
|
||||
try {
|
||||
const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots);
|
||||
return s > 0 ? s : 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
const [bountyExpireDays, setBountyExpireDays] = useState(() => {
|
||||
try {
|
||||
const d = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").expire_days);
|
||||
return (BOUNTY_EXPIRE_DAYS as readonly number[]).includes(d) ? d : 7;
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
});
|
||||
const [lotteryExpireDays, setLotteryExpireDays] = useState(() => {
|
||||
try {
|
||||
const d = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").expire_days);
|
||||
return (LOTTERY_EXPIRE_DAYS as readonly number[]).includes(d) ? d : 7;
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
});
|
||||
const canEditPoll =
|
||||
isCreate || (props.mode === "edit" && props.canEditPollOptions !== false);
|
||||
const [attachments, setAttachments] = useState<PostAttachment[]>(
|
||||
isCreate ? [] : props.initialAttachments || []
|
||||
);
|
||||
const [fileBusy, setFileBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draftBanner, setDraftBanner] = useState<ComposeDraft | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipDraftSave = useRef(true);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreate) return;
|
||||
let cancelled = false;
|
||||
fetchBoards()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const list = data.boards || [];
|
||||
setBoards(list);
|
||||
setBoardsReady(true);
|
||||
const preferred = preferredBoardId;
|
||||
const hit = preferred && list.some((b) => b.id === preferred);
|
||||
if (hit) {
|
||||
setBoardId(preferred);
|
||||
} else if (list.length > 0) {
|
||||
setBoardId((cur) =>
|
||||
cur && list.some((b) => b.id === cur) ? cur : list[0].id
|
||||
);
|
||||
}
|
||||
const draft = readDraft();
|
||||
if (
|
||||
draft &&
|
||||
(draft.title.trim() || draft.content.trim() || draft.tags.trim())
|
||||
) {
|
||||
setDraftBanner(draft);
|
||||
skipDraftSave.current = true;
|
||||
} else {
|
||||
skipDraftSave.current = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setBoardsReady(true);
|
||||
skipDraftSave.current = false;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isCreate, preferredBoardId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreate || skipDraftSave.current) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
if (!title.trim() && !content.trim() && tagList.length === 0) {
|
||||
clearDraft();
|
||||
return;
|
||||
}
|
||||
writeDraft({
|
||||
title,
|
||||
content,
|
||||
tags: serializeTags(tagList),
|
||||
boardId,
|
||||
postType,
|
||||
contentAccess,
|
||||
accessPoints,
|
||||
typeMeta,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, [
|
||||
isCreate,
|
||||
title,
|
||||
content,
|
||||
tagList,
|
||||
boardId,
|
||||
postType,
|
||||
contentAccess,
|
||||
accessPoints,
|
||||
typeMeta,
|
||||
]);
|
||||
|
||||
const restoreDraft = () => {
|
||||
if (!draftBanner) return;
|
||||
setTitle(draftBanner.title || "");
|
||||
setContent(draftBanner.content || "");
|
||||
setTagList(parseTags(draftBanner.tags || ""));
|
||||
if (draftBanner.boardId) setBoardId(draftBanner.boardId);
|
||||
if (draftBanner.postType) setPostType(normalizePostType(draftBanner.postType));
|
||||
if (draftBanner.contentAccess) {
|
||||
setContentAccess(draftBanner.contentAccess as ContentAccessValue);
|
||||
}
|
||||
if (draftBanner.accessPoints) setAccessPoints(draftBanner.accessPoints);
|
||||
if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta);
|
||||
setDraftBanner(null);
|
||||
skipDraftSave.current = false;
|
||||
toast("已恢复草稿", "ok");
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
clearDraft();
|
||||
setDraftBanner(null);
|
||||
skipDraftSave.current = false;
|
||||
};
|
||||
|
||||
const commitTagInput = useCallback(() => {
|
||||
const parts = parseTags(tagInput);
|
||||
if (parts.length === 0) {
|
||||
setTagInput("");
|
||||
return;
|
||||
}
|
||||
setTagList((prev) => {
|
||||
const next = [...prev];
|
||||
for (const p of parts) {
|
||||
if (next.length >= 12) break;
|
||||
if (!next.includes(p)) next.push(p);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTagInput("");
|
||||
}, [tagInput]);
|
||||
|
||||
const removeTag = (t: string) => {
|
||||
setTagList((prev) => prev.filter((x) => x !== t));
|
||||
};
|
||||
|
||||
const buildTypeMeta = (): string => {
|
||||
if (postType === "discussion") return "";
|
||||
if (postType === "question") {
|
||||
return JSON.stringify({ solved: false, accepted_comment_id: 0 });
|
||||
}
|
||||
if (postType === "poll") {
|
||||
return JSON.stringify({
|
||||
options: pollOptions.map((o) => o.trim()).filter(Boolean),
|
||||
multi: pollMulti,
|
||||
anonymous: pollAnonymous,
|
||||
closed: false,
|
||||
});
|
||||
}
|
||||
if (postType === "bounty") {
|
||||
return JSON.stringify({
|
||||
points: Math.max(1, bountyPoints || 1),
|
||||
accepted_comment_id: 0,
|
||||
escrowed: false,
|
||||
refunded: false,
|
||||
expired: false,
|
||||
expire_days: bountyExpireDays,
|
||||
});
|
||||
}
|
||||
if (postType === "lottery") {
|
||||
return JSON.stringify({
|
||||
slots: Math.max(1, lotterySlots || 1),
|
||||
drawn: false,
|
||||
winner_ids: [],
|
||||
closed: false,
|
||||
expire_days: lotteryExpireDays,
|
||||
});
|
||||
}
|
||||
return typeMeta.trim();
|
||||
};
|
||||
|
||||
const onPickFile = async (file: File | null) => {
|
||||
if (!file || fileBusy) return;
|
||||
if (attachments.length >= 10) {
|
||||
toast("最多 10 个附件", "error");
|
||||
return;
|
||||
}
|
||||
setFileBusy(true);
|
||||
try {
|
||||
const res = await apiUploadPostFile(file, 0);
|
||||
if (res.attachment) {
|
||||
setAttachments((prev) => [...prev, res.attachment!]);
|
||||
toast("附件已上传", "ok");
|
||||
} else {
|
||||
toast(res.error || "上传失败", "error");
|
||||
}
|
||||
} catch {
|
||||
toast("上传失败", "error");
|
||||
} finally {
|
||||
setFileBusy(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = async (id: number) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
try {
|
||||
await apiDeletePostFile(id);
|
||||
} catch {
|
||||
/* 本地已移除 */
|
||||
}
|
||||
};
|
||||
|
||||
const setAttPrice = async (id: number, price: number) => {
|
||||
const p = Math.max(0, Math.min(100000, Math.floor(price) || 0));
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, price_points: p } : a))
|
||||
);
|
||||
await apiUpdatePostFilePrice(id, p);
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
!loading &&
|
||||
title.trim().length > 0 &&
|
||||
content.trim().length > 0 &&
|
||||
(isCreate ? boardsReady && boardId > 0 : true) &&
|
||||
(contentAccess !== "points" || accessPoints > 0) &&
|
||||
(postType !== "poll" ||
|
||||
pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
|
||||
(postType !== "bounty" || bountyPoints > 0) &&
|
||||
(postType !== "lottery" || lotterySlots > 0);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const tags = serializeTags(tagList);
|
||||
const meta = buildTypeMeta();
|
||||
const attIds = attachments.map((a) => a.id);
|
||||
try {
|
||||
if (isCreate) {
|
||||
const res = await apiCreatePost({
|
||||
board_id: boardId,
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
tags,
|
||||
post_type: postType,
|
||||
content_access: contentAccess,
|
||||
access_points: contentAccess === "points" ? accessPoints : 0,
|
||||
type_meta: meta,
|
||||
attachment_ids: attIds,
|
||||
});
|
||||
if (res.post) {
|
||||
clearDraft();
|
||||
if (res.post.status === "pending") {
|
||||
toast("已提交,等待审核通过后公开", "ok");
|
||||
} else {
|
||||
toast("发布成功", "ok");
|
||||
}
|
||||
router.push(`/post/${res.post.id}`);
|
||||
} else {
|
||||
setError(mapPostError(res.error || "发帖失败"));
|
||||
}
|
||||
} else {
|
||||
const res = await apiUpdatePost(String(props.postId), {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
tags,
|
||||
content_access: contentAccess,
|
||||
access_points: contentAccess === "points" ? accessPoints : 0,
|
||||
type_meta: meta,
|
||||
attachment_ids: attIds,
|
||||
});
|
||||
if (res.post) {
|
||||
toast("已保存", "ok");
|
||||
router.push(props.cancelHref);
|
||||
} else {
|
||||
setError(mapPostError(res.error || "保存失败"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError("网络错误,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref;
|
||||
|
||||
return (
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage">
|
||||
<div className="j13-compose-body">
|
||||
{draftBanner && (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 rounded-[12px] text-[13px] shrink-0"
|
||||
style={{
|
||||
background: "var(--accent-soft)",
|
||||
color: "var(--ink)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--accent) 18%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
检测到未发布的本地草稿
|
||||
{draftBanner.updatedAt
|
||||
? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})`
|
||||
: ""}
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={restoreDraft}>
|
||||
恢复
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={discardDraft}>
|
||||
丢弃
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="alert-error shrink-0">{error}</div>}
|
||||
|
||||
{/* 帖子类型 */}
|
||||
<div className="shrink-0">
|
||||
<div className="field-label mb-2">帖子类型</div>
|
||||
<div className="j13-compose-typebar" role="radiogroup" aria-label="帖子类型">
|
||||
{POST_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={postType === t.value}
|
||||
className={`j13-compose-typechip${postType === t.value ? " is-on" : ""}`}
|
||||
disabled={loading || (!isCreate && t.value !== postType)}
|
||||
data-tip={!isCreate && t.value !== postType ? "编辑时不可更改类型" : t.hint}
|
||||
onClick={() => setPostType(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息:板块 + 标签(标签 flex 占满) + 可见性 */}
|
||||
<div className="j13-compose-meta shrink-0">
|
||||
<div className="j13-compose-meta-board">
|
||||
<label className="field-label" htmlFor="compose-board">
|
||||
板块
|
||||
</label>
|
||||
{isCreate ? (
|
||||
<BoardPicker
|
||||
id="compose-board"
|
||||
boards={boards}
|
||||
value={boardId}
|
||||
onChange={setBoardId}
|
||||
disabled={loading}
|
||||
loading={!boardsReady}
|
||||
/>
|
||||
) : (
|
||||
<BoardBadge
|
||||
name={props.boardName || "未知板块"}
|
||||
colorIndex={props.boardColorIndex ?? 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-meta-tags">
|
||||
<label className="field-label" htmlFor="compose-tag-input">
|
||||
标签
|
||||
<span className="font-normal" style={{ color: "var(--ink-3)" }}>
|
||||
{" "}
|
||||
· 可选,最多 12 个
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
className="j13-tag-field"
|
||||
onClick={() => document.getElementById("compose-tag-input")?.focus()}
|
||||
>
|
||||
{tagList.map((t) => (
|
||||
<span key={t} className="chip inline-flex items-center gap-1 !py-0.5">
|
||||
# {t}
|
||||
<button
|
||||
type="button"
|
||||
data-tip={`移除 ${t}`}
|
||||
aria-label={`移除标签 ${t}`}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(t);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
id="compose-tag-input"
|
||||
value={tagInput}
|
||||
disabled={loading || tagList.length >= 12}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commitTagInput();
|
||||
} else if (e.key === "Backspace" && !tagInput && tagList.length > 0) {
|
||||
removeTag(tagList[tagList.length - 1]);
|
||||
}
|
||||
}}
|
||||
onBlur={commitTagInput}
|
||||
placeholder={tagList.length === 0 ? "输入后回车添加" : ""}
|
||||
className="j13-tag-field-input flex-1 min-w-[8rem] self-stretch h-8 text-[13px]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-meta-access">
|
||||
<label className="field-label" htmlFor="compose-access">
|
||||
正文可见性
|
||||
</label>
|
||||
<select
|
||||
id="compose-access"
|
||||
className="j13-compose-field"
|
||||
value={contentAccess}
|
||||
disabled={loading}
|
||||
onChange={(e) => setContentAccess(e.target.value as ContentAccessValue)}
|
||||
>
|
||||
{CONTENT_ACCESS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{contentAccess === "points" && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="meta text-[12.5px] shrink-0" htmlFor="compose-access-pts">
|
||||
所需积分
|
||||
</label>
|
||||
<input
|
||||
id="compose-access-pts"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={accessPoints}
|
||||
disabled={loading}
|
||||
onChange={(e) => setAccessPoints(Number(e.target.value) || 0)}
|
||||
className="j13-compose-field !py-1.5 w-28"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型专属面板 */}
|
||||
{postType === "poll" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
投票选项
|
||||
</p>
|
||||
{!canEditPoll && (
|
||||
<p className="meta text-[12.5px]">已有人投票,无法修改选项、单/多选或匿名设置</p>
|
||||
)}
|
||||
{pollOptions.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
className="j13-compose-field flex-1"
|
||||
value={opt}
|
||||
maxLength={64}
|
||||
placeholder={`选项 ${i + 1}`}
|
||||
disabled={loading || !canEditPoll}
|
||||
onChange={(e) => {
|
||||
const next = [...pollOptions];
|
||||
next[i] = e.target.value;
|
||||
setPollOptions(next);
|
||||
}}
|
||||
/>
|
||||
{pollOptions.length > 2 && canEditPoll && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
onClick={() => setPollOptions((p) => p.filter((_, j) => j !== i))}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{pollOptions.length < 10 && canEditPoll && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setPollOptions((p) => [...p, ""])}
|
||||
>
|
||||
添加选项
|
||||
</button>
|
||||
)}
|
||||
<label className="meta text-[13px] inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pollMulti}
|
||||
disabled={loading || !canEditPoll}
|
||||
onChange={(e) => setPollMulti(e.target.checked)}
|
||||
/>
|
||||
允许多选
|
||||
</label>
|
||||
<label className="meta text-[13px] inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pollAnonymous}
|
||||
disabled={loading || !canEditPoll}
|
||||
onChange={(e) => setPollAnonymous(e.target.checked)}
|
||||
/>
|
||||
匿名投票
|
||||
</label>
|
||||
</div>
|
||||
{!pollAnonymous && (
|
||||
<p className="meta text-[12px]">非匿名:投票结果公开后可看到每位投票人</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{postType === "bounty" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏积分
|
||||
</p>
|
||||
<p className="meta text-[12.5px]">
|
||||
发布时从你的积分余额托管;到期未采纳将自动退回。采纳回答后发给对方。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={bountyPoints}
|
||||
disabled={loading || !isCreate}
|
||||
onChange={(e) => setBountyPoints(Number(e.target.value) || 0)}
|
||||
className="j13-compose-field w-36"
|
||||
/>
|
||||
<span className="meta text-[13px]">积分</span>
|
||||
{!isCreate && (
|
||||
<span className="meta text-[12.5px]">编辑不可更改已托管金额</span>
|
||||
)}
|
||||
</div>
|
||||
{isCreate && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="meta text-[13px]">过期天数</span>
|
||||
{BOUNTY_EXPIRE_DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`btn btn-sm ${bountyExpireDays === d ? "btn-primary" : "btn-line"}`}
|
||||
disabled={loading}
|
||||
onClick={() => setBountyExpireDays(d)}
|
||||
>
|
||||
{d} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{postType === "lottery" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖设置
|
||||
</p>
|
||||
<p className="meta text-[12.5px]">
|
||||
对本帖发表评论的用户自动进入奖池(楼主除外);截止时可自动开奖。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="meta text-[13px]" htmlFor="lottery-slots">
|
||||
中奖名额
|
||||
</label>
|
||||
<input
|
||||
id="lottery-slots"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={lotterySlots}
|
||||
disabled={loading || !isCreate}
|
||||
onChange={(e) => setLotterySlots(Number(e.target.value) || 1)}
|
||||
className="j13-compose-field w-28"
|
||||
/>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="meta text-[13px]">截止天数</span>
|
||||
{LOTTERY_EXPIRE_DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`btn btn-sm ${lotteryExpireDays === d ? "btn-primary" : "btn-line"}`}
|
||||
disabled={loading}
|
||||
onClick={() => setLotteryExpireDays(d)}
|
||||
>
|
||||
{d} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 大标题 */}
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-end justify-between gap-3 mb-1">
|
||||
<label className="sr-only" htmlFor="compose-title">
|
||||
标题
|
||||
</label>
|
||||
<span className="meta text-[12px] tabular-nums ml-auto">
|
||||
{title.length}/{TITLE_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="compose-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
|
||||
required
|
||||
maxLength={TITLE_MAX}
|
||||
placeholder="一句话说清楚你要讨论什么"
|
||||
className="j13-compose-title"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 正文 */}
|
||||
<div className="j13-compose-canvas">
|
||||
<label className="sr-only" htmlFor="compose-content">
|
||||
正文
|
||||
</label>
|
||||
<MarkdownEditor
|
||||
id="compose-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
disabled={loading}
|
||||
rows={18}
|
||||
placeholder={
|
||||
"从这里开始写正文…\n\n支持 Markdown;可用工具栏插入格式与图片。可点全屏沉浸写作。"
|
||||
}
|
||||
onSubmitShortcut={() => {
|
||||
if (canSubmit) void handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className="j13-compose-attach shrink-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="field-label !mb-0">附件</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1.5"
|
||||
disabled={loading || fileBusy || attachments.length >= 10}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
{fileBusy ? "上传中…" : "添加文件"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
<p className="meta text-[12.5px] mb-2">
|
||||
单文件 ≤20MB,最多 10 个;可设免费或积分下载
|
||||
</p>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="meta text-[13px] py-2">暂无附件</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id} className="j13-compose-attach-row">
|
||||
<Download size={15} className="shrink-0 opacity-50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[13.5px]">{a.name}</div>
|
||||
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
||||
</div>
|
||||
<label className="meta text-[12px] flex items-center gap-1.5 shrink-0">
|
||||
积分
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={a.price_points}
|
||||
disabled={loading}
|
||||
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
|
||||
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
|
||||
data-tip="0 = 免费下载"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
data-tip="移除附件"
|
||||
aria-label="移除附件"
|
||||
onClick={() => void removeAttachment(a.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="btn btn-primary min-w-[8.5rem] !px-7 !py-3 !text-[15px]"
|
||||
>
|
||||
{loading
|
||||
? isCreate
|
||||
? "发布中…"
|
||||
: "保存中…"
|
||||
: isCreate
|
||||
? "发布帖子"
|
||||
: "保存修改"}
|
||||
</button>
|
||||
{isCreate ? (
|
||||
<Link href={cancelHref} className="btn btn-line !py-3">
|
||||
取消
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(cancelHref)}
|
||||
className="btn btn-line !py-3"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<span className="meta text-[12.5px] sm:ml-auto max-w-md">
|
||||
{isCreate
|
||||
? "普通用户发帖需审核 · Ctrl/⌘+Enter 提交 · Esc 退出全屏"
|
||||
: "保存后立即生效 · 类型不可更改"}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
421
frontend/components/PostInteractPanels.tsx
Normal file
421
frontend/components/PostInteractPanels.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
apiVotePoll,
|
||||
apiClosePoll,
|
||||
apiRefundBounty,
|
||||
apiCloseLottery,
|
||||
apiDrawLottery,
|
||||
apiSolveQuestion,
|
||||
apiReopenQuestion,
|
||||
type PollState,
|
||||
type BountyState,
|
||||
type LotteryState,
|
||||
type QuestionState,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
export default function PostInteractPanels({
|
||||
postId,
|
||||
question,
|
||||
poll,
|
||||
bounty,
|
||||
lottery,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
question?: QuestionState | null;
|
||||
poll?: PollState | null;
|
||||
bounty?: BountyState | null;
|
||||
lottery?: LotteryState | null;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
if (!question && !poll && !bounty && !lottery) return null;
|
||||
return (
|
||||
<div className="space-y-4 mb-8">
|
||||
{question && <QuestionPanel postId={postId} state={question} />}
|
||||
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
|
||||
{bounty && <BountyPanel postId={postId} state={bounty} />}
|
||||
{lottery && <LotteryPanel postId={postId} state={lottery} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEndsAt(iso?: string) {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function QuestionPanel({ postId, state }: { postId: number; state: QuestionState }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
if (res.post) {
|
||||
toast(ok, "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
问答 · {state.solved ? "已解决" : "未解决"}
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{state.solved
|
||||
? state.accepted_comment_id
|
||||
? `已采纳评论 #${state.accepted_comment_id}`
|
||||
: "作者已标记为已解决"
|
||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.can_solve && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
)}
|
||||
{state.can_reopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
|
||||
>
|
||||
重新打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PollPanel({
|
||||
postId,
|
||||
state,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
state: PollState;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [sel, setSel] = useState<number[]>(state.my_options || []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
if (!state.can_vote) return;
|
||||
if (state.multi) {
|
||||
setSel((prev) =>
|
||||
prev.includes(idx) ? prev.filter((x) => x !== idx) : [...prev, idx]
|
||||
);
|
||||
} else {
|
||||
setSel([idx]);
|
||||
}
|
||||
};
|
||||
|
||||
const vote = async () => {
|
||||
if (sel.length === 0) {
|
||||
toast("请先选择选项", "error");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiVotePoll(String(postId), sel);
|
||||
if (res.post) {
|
||||
toast("投票成功", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "投票失败", "error");
|
||||
} catch {
|
||||
toast("投票失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiClosePoll(String(postId));
|
||||
if (res.post) {
|
||||
toast("已结束投票", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
投票{state.multi ? "(多选)" : "(单选)"}
|
||||
{state.anonymous ? " · 匿名" : " · 实名"}
|
||||
</h2>
|
||||
<span className="meta text-[12.5px]">
|
||||
{state.voters} 人参与 · {state.total_votes} 票
|
||||
{state.closed ? " · 已结束" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{state.options.map((o) => {
|
||||
const checked = sel.includes(o.index);
|
||||
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
|
||||
return (
|
||||
<li key={o.index}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!state.can_vote || busy}
|
||||
onClick={() => toggle(o.index)}
|
||||
className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
|
||||
background: checked ? "var(--accent-soft)" : "var(--panel)",
|
||||
}}
|
||||
>
|
||||
{showResults && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 opacity-30"
|
||||
style={{
|
||||
width: `${o.percent}%`,
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
||||
{o.text}
|
||||
</span>
|
||||
{showResults && (
|
||||
<span className="meta tabular-nums text-[12.5px] shrink-0">
|
||||
{o.votes} · {o.percent}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{namedVoters.length > 0 && (
|
||||
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1">
|
||||
{namedVoters.map((v) => (
|
||||
<li key={v.id}>
|
||||
<Link
|
||||
href={`/u/${v.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px] font-semibold"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||
{v.nickname || v.username}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{state.can_vote && (
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void vote()}>
|
||||
提交投票
|
||||
</button>
|
||||
)}
|
||||
{!loggedIn && !state.closed && (
|
||||
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
||||
登录后投票
|
||||
</Link>
|
||||
)}
|
||||
{state.can_close && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void close()}
|
||||
>
|
||||
结束投票
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BountyPanel({ postId, state }: { postId: number; state: BountyState }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const refund = async () => {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
window.setTimeout(() => setConfirming(false), 3000);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiRefundBounty(String(postId));
|
||||
if (res.post) {
|
||||
toast("悬赏已退回", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "退回失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = state.expired
|
||||
? "已过期,积分已自动退回"
|
||||
: state.refunded
|
||||
? "已退回给发帖人"
|
||||
: state.accepted_comment_id
|
||||
? `已采纳评论 #${state.accepted_comment_id}`
|
||||
: state.escrowed
|
||||
? "积分已托管,采纳优质回答后发放"
|
||||
: "悬赏未托管";
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏 {state.points} 积分
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{statusText}
|
||||
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{state.can_refund && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
|
||||
disabled={busy}
|
||||
onClick={() => void refund()}
|
||||
>
|
||||
{confirming ? "再点确认退回" : "退回悬赏"}
|
||||
</button>
|
||||
)}
|
||||
{state.can_accept && (
|
||||
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LotteryPanel({
|
||||
postId,
|
||||
state,
|
||||
}: {
|
||||
postId: number;
|
||||
state: LotteryState;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
if (res.post) {
|
||||
toast(ok, "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖 · {state.slots} 个名额
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
已有 {state.entry_count} 名回帖用户参与
|
||||
{state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
|
||||
{state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
|
||||
{state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
{!state.drawn && (
|
||||
<p className="meta text-[12px] mt-1">对本帖发表评论即可参与(楼主除外)</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.can_close && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiCloseLottery(String(postId)), "已截止")}
|
||||
>
|
||||
提前截止
|
||||
</button>
|
||||
)}
|
||||
{state.can_draw && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiDrawLottery(String(postId)), "开奖完成")}
|
||||
>
|
||||
开奖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{state.drawn && (
|
||||
<div>
|
||||
<p className="font-bold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
|
||||
</p>
|
||||
{state.winners.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-3">
|
||||
{state.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
<Link
|
||||
href={`/u/${w.id}`}
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
|
||||
<span className="text-[13px] font-bold">{w.nickname || w.username}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { PostListItem } from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } from "@/lib/postMeta";
|
||||
import LikeButton from "./LikeButton";
|
||||
import Avatar from "./Avatar";
|
||||
import { IconComment, IconEye } from "./Icons";
|
||||
@@ -28,6 +29,8 @@ export default function PostRow({
|
||||
}) {
|
||||
const lastReply = post.last_reply;
|
||||
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||
const pt = normalizePostType(post.post_type);
|
||||
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -52,23 +55,36 @@ export default function PostRow({
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* 仅置顶/推荐在标题左侧 */}
|
||||
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
|
||||
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
className="stretch-link min-w-0 flex-1"
|
||||
aria-label={post.title}
|
||||
>
|
||||
<h3
|
||||
className={`font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)] ${
|
||||
compact ? "text-[15px]" : "text-[16px] sm:text-[17px]"
|
||||
}`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
className="stretch-link min-w-0 shrink"
|
||||
aria-label={post.title}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<h3
|
||||
className={`font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)] ${
|
||||
compact ? "text-[15px]" : "text-[16px] sm:text-[17px]"
|
||||
}`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{/* 其余徽章紧跟标题右侧 */}
|
||||
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||
{pt !== "discussion" && pt !== "question" && (
|
||||
<span className="badge-type shrink-0">{postTypeLabel(post.post_type)}</span>
|
||||
)}
|
||||
{statusBadge && (
|
||||
<span className="badge-access shrink-0">{statusBadge}</span>
|
||||
)}
|
||||
{post.content_access && post.content_access !== "public" && (
|
||||
<span className="badge-access shrink-0">{contentAccessLabel(post.content_access)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`meta relative z-[2] flex items-center gap-x-2 gap-y-1 flex-wrap ${
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function RoleBadge({
|
||||
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
|
||||
} ${className}`}
|
||||
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}
|
||||
title={title ?? label}
|
||||
data-tip={title ?? label}
|
||||
>
|
||||
<Icon size={size} className="shrink-0" />
|
||||
{truncate ? <span className="overflow-hidden text-ellipsis">{label}</span> : label}
|
||||
|
||||
135
frontend/components/TipHost.tsx
Normal file
135
frontend/components/TipHost.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type TipState = {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
place: "top" | "bottom";
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
|
||||
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
|
||||
*/
|
||||
export default function TipHost() {
|
||||
const [tip, setTip] = useState<TipState>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const activeEl = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
activeEl.current = null;
|
||||
setTip(null);
|
||||
};
|
||||
|
||||
const placeTip = (el: Element, text: string) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const preferBottom = rect.top < 48;
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = preferBottom ? rect.bottom + 8 : rect.top - 8;
|
||||
setTip({
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
place: preferBottom ? "bottom" : "top",
|
||||
});
|
||||
};
|
||||
|
||||
const onOver = (e: Event) => {
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
const host = t.closest("[data-tip]");
|
||||
if (!host) {
|
||||
if (activeEl.current) hide();
|
||||
return;
|
||||
}
|
||||
const text = host.getAttribute("data-tip")?.trim();
|
||||
if (!text) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
activeEl.current = host;
|
||||
placeTip(host, text);
|
||||
};
|
||||
|
||||
const onOut = (e: Event) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
const related = e.relatedTarget;
|
||||
if (
|
||||
related instanceof Node &&
|
||||
activeEl.current &&
|
||||
activeEl.current.contains(related)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hide();
|
||||
};
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
if (!activeEl.current) return;
|
||||
const text = activeEl.current.getAttribute("data-tip")?.trim();
|
||||
if (!text) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
placeTip(activeEl.current, text);
|
||||
};
|
||||
|
||||
// mouseover 冒泡,便于 closest;立即显示,无延时
|
||||
document.addEventListener("mouseover", onOver);
|
||||
document.addEventListener("mouseout", onOut);
|
||||
document.addEventListener("focusin", onOver);
|
||||
document.addEventListener("focusout", onOut);
|
||||
window.addEventListener("scroll", onScrollOrResize, true);
|
||||
window.addEventListener("resize", onScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseover", onOver);
|
||||
document.removeEventListener("mouseout", onOut);
|
||||
document.removeEventListener("focusin", onOver);
|
||||
document.removeEventListener("focusout", onOut);
|
||||
window.removeEventListener("scroll", onScrollOrResize, true);
|
||||
window.removeEventListener("resize", onScrollOrResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 测量后微调,避免贴边裁切
|
||||
useEffect(() => {
|
||||
if (!tip || !tipRef.current) return;
|
||||
const el = tipRef.current;
|
||||
const w = el.offsetWidth;
|
||||
const pad = 10;
|
||||
let left = tip.x;
|
||||
const half = w / 2;
|
||||
if (left - half < pad) left = pad + half;
|
||||
if (left + half > window.innerWidth - pad) {
|
||||
left = window.innerWidth - pad - half;
|
||||
}
|
||||
el.style.left = `${left}px`;
|
||||
}, [tip]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
className="j13-tip-bubble"
|
||||
data-place={tip.place}
|
||||
style={{
|
||||
left: tip.x,
|
||||
top: tip.y,
|
||||
transform:
|
||||
tip.place === "top"
|
||||
? "translate(-50%, -100%)"
|
||||
: "translate(-50%, 0)",
|
||||
}}
|
||||
>
|
||||
{tip.text}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -50,8 +50,16 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
|
||||
{guest
|
||||
? "登录后每日签到领积分"
|
||||
: checked
|
||||
? `连签 ${status?.streak ?? 0} 天 · 累计 ${status?.total_points ?? 0} 积分`
|
||||
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
|
||||
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
|
||||
{!guest && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/points" className="hover:text-[var(--accent)] transition-colors">
|
||||
流水
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{guest ? (
|
||||
|
||||
@@ -67,7 +67,7 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title="近 5 分钟在线"
|
||||
data-tip="近 5 分钟在线"
|
||||
>
|
||||
<span className="j13-pulse-dot" aria-hidden="true" />
|
||||
实时
|
||||
@@ -124,7 +124,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
<span
|
||||
className="mt-1.5 block truncate text-[13px] leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
title={a.title}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</span>
|
||||
@@ -151,7 +151,7 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
||||
<ol className="j13-ranklist">
|
||||
{posts.map((p, i) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank group" title={p.title}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank group" data-tip={p.title}>
|
||||
<span className="j13-rank-no">{i + 1}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
@@ -200,7 +200,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
<span
|
||||
className="w-full truncate text-center text-[11px]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
title={name}
|
||||
data-tip={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
@@ -232,7 +232,7 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" title={name}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar} size={30} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span
|
||||
@@ -294,7 +294,7 @@ function FriendLinks() {
|
||||
{l.name}
|
||||
</a>
|
||||
) : (
|
||||
<span key={l.name} className="j13-chip" title="友链待配置">
|
||||
<span key={l.name} className="j13-chip" data-tip="友链待配置">
|
||||
{l.name}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,11 @@ function boardHref(id: number) {
|
||||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user: User | null }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const composeHref = user ? "/compose" : "/login?redirect=/compose";
|
||||
const composePath =
|
||||
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
|
||||
const composeHref = user
|
||||
? composePath
|
||||
: `/login?redirect=${encodeURIComponent(composePath)}`;
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:block w-56 xl:w-60 shrink-0" aria-label="工作台导航">
|
||||
@@ -96,7 +100,11 @@ export function WorkspaceNavMobile({
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const composeHref = user ? "/compose" : "/login?redirect=/compose";
|
||||
const composePath =
|
||||
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
|
||||
const composeHref = user
|
||||
? composePath
|
||||
: `/login?redirect=${encodeURIComponent(composePath)}`;
|
||||
|
||||
return (
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6">
|
||||
|
||||
Reference in New Issue
Block a user