feat: 类型帖互动、积分经济与发帖体验

补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:17:07 +08:00
parent 1ef3d8e299
commit b9ba8cb8c9
58 changed files with 6913 additions and 506 deletions

View 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(`![${alt}](${res.url})`);
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>
);
}