feat(attach): 附件可配置限额、流式落盘与分阶段上传

管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 07:33:16 +08:00
parent 6cc434f103
commit 61bf9fb48c
24 changed files with 2513 additions and 307 deletions

View File

@@ -15,17 +15,26 @@ import {
Wrench,
Palette,
FileText,
Paperclip,
Image as ImageIcon,
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
AdminExtChipEditor,
AdminField,
AdminPageHeader,
AdminSection,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
function extsEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
type SectionId =
| "basic"
| "content"
@@ -119,7 +128,18 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
const [togglingLink, setTogglingLink] = useState(false);
const [active, setActive] = useState<SectionId>("basic");
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
const [savedExts, setSavedExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
const [attMaxMB, setAttMaxMB] = useState(initial.attachment_max_mb || 20);
const [attMaxCount, setAttMaxCount] = useState(initial.attachment_max_count || 10);
const [imageMaxMB, setImageMaxMB] = useState(initial.image_max_mb || 5);
const [togglingExtLimit, setTogglingExtLimit] = useState(false);
const [savingLimits, setSavingLimits] = useState(false);
const [savingExts, setSavingExts] = useState(false);
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
useEffect(() => {
const ids = NAV.map((n) => n.id);
@@ -232,6 +252,75 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}
};
const toggleExtLimit = async () => {
if (togglingExtLimit) return;
const next = !extLimit;
setTogglingExtLimit(true);
try {
const res = await apiUpdateSiteSettings({ attachment_ext_limit: next });
setExtLimit(res.attachment_ext_limit !== false);
toast(res.attachment_ext_limit !== false ? "已开启附件类型限制" : "已关闭附件类型限制", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingExtLimit(false);
}
};
const saveUploadLimits = async (patch: {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}) => {
if (savingLimits) return;
setSavingLimits(true);
try {
const res = await apiUpdateSiteSettings(patch);
setAttMaxMB(res.attachment_max_mb);
setAttMaxCount(res.attachment_max_count);
setImageMaxMB(res.image_max_mb);
toast("上传限额已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingLimits(false);
}
};
// 步进器手输会连续触发 onChange,防抖后再写库
const limitsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleLimitSave = (patch: {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}) => {
if (limitsTimer.current) clearTimeout(limitsTimer.current);
limitsTimer.current = setTimeout(() => {
void saveUploadLimits(patch);
}, 450);
};
useEffect(() => {
return () => {
if (limitsTimer.current) clearTimeout(limitsTimer.current);
};
}, []);
const saveExts = async () => {
if (savingExts || !extsDirty) return;
setSavingExts(true);
try {
const res = await apiUpdateSiteSettings({ attachment_exts: exts });
setExts([...res.attachment_exts]);
setSavedExts([...res.attachment_exts]);
toast("附件扩展名已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingExts(false);
}
};
return (
<div>
<AdminPageHeader
@@ -310,7 +399,7 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
</AdminSection>
</div>
<div id="settings-content" className="scroll-mt-4">
<div id="settings-content" className="scroll-mt-4 flex flex-col gap-4">
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
<SwitchRow
bordered
@@ -380,6 +469,137 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}
/>
</AdminSection>
<AdminSection
title="附件与上传"
description="帖子附件限额、扩展名白名单与正文插图体积;改后经实时通道同步到发帖页"
>
<SwitchRow
bordered
title={
<>
<Paperclip size={15} /> 附件单文件上限
</>
}
description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。"
control={
<AdminStepper
value={attMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="附件单文件上限"
disabled={savingLimits}
onChange={(n) => {
setAttMaxMB(n);
scheduleLimitSave({ attachment_max_mb: n });
}}
/>
}
/>
{attMaxMB >= 50 ? (
<p className="j13-ext-warn !mt-0 !mb-3" role="status">
当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。
</p>
) : null}
<SwitchRow
bordered
title={
<>
<Paperclip size={15} /> 每帖附件个数
</>
}
description="单帖(含草稿)最多可绑定的附件数量。"
control={
<AdminStepper
value={attMaxCount}
min={1}
max={20}
unit="个"
ariaLabel="每帖附件个数"
disabled={savingLimits}
onChange={(n) => {
setAttMaxCount(n);
scheduleLimitSave({ attachment_max_count: n });
}}
/>
}
/>
<SwitchRow
bordered
title={
<>
<ImageIcon size={15} /> 正文插图上限
</>
}
description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。"
control={
<AdminStepper
value={imageMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="正文插图上限"
disabled={savingLimits}
onChange={(n) => {
setImageMaxMB(n);
scheduleLimitSave({ image_max_mb: n });
}}
/>
}
/>
{imageMaxMB >= 10 ? (
<p className="j13-ext-warn !mt-0 !mb-3" role="status">
插图 ≥10MB:解码头部更耗内存,建议保持较小值。
</p>
) : null}
<SwitchRow
bordered
title={
<>
<Paperclip size={15} /> 限制附件扩展名
</>
}
description={
extLimit
? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。"
: "任意格式可传,仍受体积上限与下载沙箱约束。"
}
control={
<AdminSwitch
checked={extLimit}
disabled={togglingExtLimit}
onChange={() => void toggleExtLimit()}
label="限制附件扩展名"
/>
}
/>
<div className="pt-2">
<p className="text-[13.5px] font-medium mb-2" style={{ color: "var(--ink)" }}>
允许的扩展名
</p>
<AdminExtChipEditor
value={exts}
onChange={setExts}
disabled={!extLimit || savingExts}
/>
<div className="flex justify-end mt-3">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!extsDirty || savingExts || !extLimit}
onClick={() => void saveExts()}
>
{savingExts ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存扩展名
</button>
</div>
</div>
</AdminSection>
</div>
<div id="settings-brand" className="scroll-mt-4">

View File

@@ -404,6 +404,242 @@ body {
border: 1px solid var(--line);
background: var(--panel);
}
.j13-compose-attach-section {
background: var(--panel);
border-color: color-mix(in srgb, var(--line) 80%, var(--accent) 20%);
}
.j13-compose-attach-title {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 13px !important;
letter-spacing: 0.02em;
color: var(--ink-2) !important;
}
.j13-compose-attach-badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
background: var(--accent-soft);
}
.j13-compose-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 7.5rem;
padding: 1.15rem 1rem;
border-radius: 14px;
border: 1.5px dashed color-mix(in srgb, var(--accent) 42%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
cursor: pointer;
text-align: center;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.j13-compose-drop:hover,
.j13-compose-drop:focus-visible {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.j13-compose-drop.is-over {
border-color: var(--accent);
border-style: solid;
background: color-mix(in srgb, var(--accent-soft) 85%, var(--panel));
}
.j13-compose-drop.is-disabled {
opacity: 0.55;
pointer-events: none;
}
.j13-compose-drop-icon {
color: var(--accent);
opacity: 0.85;
}
.j13-compose-drop-lead {
margin: 0;
font-size: 14px;
font-weight: 650;
color: var(--ink);
}
.j13-compose-drop-hint {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--ink-3);
max-width: 36rem;
}
.j13-compose-att-list,
.j13-compose-att-queue {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.j13-compose-att-pending {
margin-top: 0.75rem;
padding: 12px;
border-radius: 14px;
border: 1.5px solid color-mix(in srgb, var(--accent) 40%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 70%, var(--panel));
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.j13-compose-att-pending-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.j13-compose-att-pending-title {
margin: 0;
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
}
.j13-compose-att-upload-btn {
width: 100%;
justify-content: center;
gap: 0.45rem;
min-height: 42px;
font-size: 14.5px;
font-weight: 600;
/* 次于发布主 CTA:白底 + 实线 accent,在 soft 待传区里仍清晰 */
color: var(--accent);
background: var(--panel);
border-color: var(--accent);
border-width: 1.5px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06),
0 2px 8px color-mix(in srgb, var(--accent) 14%, transparent);
}
.j13-compose-att-upload-btn:hover:not(:disabled) {
color: var(--accent-on);
background: var(--accent);
border-color: var(--accent);
}
.j13-compose-att-upload-btn:active:not(:disabled) {
background: var(--accent-hi);
border-color: var(--accent-hi);
color: var(--accent-on);
}
.j13-compose-att-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
}
.j13-compose-att-card.is-error {
border-color: color-mix(in srgb, var(--danger, #c44) 45%, var(--line));
}
.j13-compose-att-name {
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
}
.j13-compose-att-bar {
margin-top: 6px;
height: 4px;
border-radius: 999px;
background: var(--panel-2);
overflow: hidden;
}
.j13-compose-att-bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width 0.2s ease;
}
.j13-ext-editor.is-disabled {
opacity: 0.55;
pointer-events: none;
}
.j13-ext-warn {
margin: 0 0 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
font-size: 12.5px;
line-height: 1.45;
color: color-mix(in srgb, var(--gold) 85%, var(--ink));
background: color-mix(in srgb, var(--gold) 14%, var(--panel));
border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line));
}
.j13-ext-groups {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.65rem;
}
.j13-ext-wall {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
min-height: 2.75rem;
padding: 0.55rem 0.65rem;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel-2);
cursor: text;
}
.j13-ext-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.35rem 0.2rem 0.55rem;
border-radius: 999px;
font-size: 12.5px;
font-weight: 650;
font-variant-numeric: tabular-nums;
color: var(--ink);
background: var(--panel);
border: 1px solid var(--line);
}
.j13-ext-chip-x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-3);
cursor: pointer;
}
.j13-ext-chip-x:hover {
color: var(--ink);
background: color-mix(in srgb, var(--ink) 8%, transparent);
}
.j13-ext-input-wrap {
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-width: 9rem;
flex: 1 1 8rem;
}
.j13-ext-input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font-size: 13px;
color: var(--ink);
padding: 0.2rem 0;
}
.j13-compose-att-paid {
color: var(--gold);
font-weight: 650;

View File

@@ -0,0 +1,495 @@
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type DragEvent,
type ReactNode,
} from "react";
import { Loader2, Paperclip, Upload, X } from "lucide-react";
import {
apiDeletePostFile,
apiUpdatePostFilePrice,
apiUploadPostFile,
type PostAttachment,
} from "@/lib/api";
import { fileExtBadge, fileKind } from "@/lib/fileKind";
import { buildAcceptAttr, rejectPostFile } from "@/lib/postFile";
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`;
}
type UploadLimits = {
maxMB: number;
maxCount: number;
extLimit: boolean;
exts: string[];
};
/** 待上传(仅本地,未请求服务器) */
type PendingItem = {
key: string;
file: File;
name: string;
size: number;
};
/** 正在上传的进度项 */
type QueueItem = {
key: string;
name: string;
size: number;
progress: number;
error?: string;
};
type Props = {
attachments: PostAttachment[];
onChange: (next: PostAttachment[]) => void;
limits: UploadLimits;
disabled?: boolean;
/** 任一上传进行中时通知父级禁用发布 */
onBusyChange?: (busy: boolean) => void;
/** 待传(未点上传)数量变化,供父级提交前拦截提示 */
onPendingChange?: (count: number) => void;
/** 供底栏滚入定位 */
sectionRef?: React.RefObject<HTMLElement | null>;
};
export default function ComposeAttachments({
attachments,
onChange,
limits,
disabled,
onBusyChange,
onPendingChange,
sectionRef,
}: Props) {
const fileRef = useRef<HTMLInputElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const [dragOver, setDragOver] = useState(false);
const [pending, setPending] = useState<PendingItem[]>([]);
const [queue, setQueue] = useState<QueueItem[]>([]);
const [uploading, setUploading] = useState(false);
const priceTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const titleId = useId();
const busy = uploading || queue.length > 0;
useEffect(() => {
onBusyChange?.(busy);
}, [busy, onBusyChange]);
useEffect(() => {
onPendingChange?.(pending.length);
}, [pending.length, onPendingChange]);
useEffect(() => {
return () => {
priceTimers.current.forEach((t) => clearTimeout(t));
priceTimers.current.clear();
onPendingChange?.(0);
};
}, [onPendingChange]);
const accept = buildAcceptAttr(limits.exts, limits.extLimit);
const hintLine = limits.extLimit
? `单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个;允许 ${
limits.exts.length > 8
? limits.exts.slice(0, 8).map((e) => `.${e}`).join(" ") + "…"
: limits.exts.map((e) => `.${e}`).join(" ") || "(列表为空)"
}`
: `未限制类型,单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个`;
/** 仅加入待传列表,不立刻上传 */
const stageFiles = useCallback(
(files: FileList | File[]) => {
const list = Array.from(files);
if (!list.length || uploading || disabled) return;
const next: PendingItem[] = [];
let currentCount = attachments.length + pending.length;
for (const file of list) {
const rej = rejectPostFile(file, {
limit: limits.extLimit,
exts: limits.exts,
maxMB: limits.maxMB,
maxCount: limits.maxCount,
currentCount,
});
if (rej.reason) {
toast(rej.message || "无法添加", "error");
continue;
}
next.push({
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${file.name}`,
file,
name: file.name,
size: file.size,
});
currentCount++;
}
if (!next.length) return;
setPending((prev) => [...prev, ...next]);
if (fileRef.current) fileRef.current.value = "";
},
[attachments.length, disabled, limits, pending.length, uploading]
);
const removePending = (key: string) => {
setPending((prev) => prev.filter((p) => p.key !== key));
};
const clearPending = () => {
if (uploading) return;
setPending([]);
};
/** 用户确认后再真正上传 */
const uploadPending = useCallback(async () => {
if (!pending.length || uploading || disabled) return;
const batch = [...pending];
setPending([]);
setUploading(true);
const items: QueueItem[] = batch.map((p) => ({
key: p.key,
name: p.name,
size: p.size,
progress: 8,
}));
setQueue(items);
const added: PostAttachment[] = [];
let liveCount = attachments.length;
for (let i = 0; i < batch.length; i++) {
const item = batch[i];
const key = item.key;
const rej = rejectPostFile(item.file, {
limit: limits.extLimit,
exts: limits.exts,
maxMB: limits.maxMB,
maxCount: limits.maxCount,
currentCount: liveCount,
});
if (rej.reason) {
setQueue((prev) =>
prev.map((q) =>
q.key === key ? { ...q, error: rej.message || "无法上传", progress: 100 } : q
)
);
toast(rej.message || "无法上传", "error");
continue;
}
setQueue((prev) =>
prev.map((q) => (q.key === key ? { ...q, progress: 35 } : q))
);
try {
const res = await apiUploadPostFile(item.file, 0);
if (res.attachment) {
added.push(res.attachment);
liveCount++;
setQueue((prev) =>
prev.map((q) => (q.key === key ? { ...q, progress: 100 } : q))
);
} else {
setQueue((prev) =>
prev.map((q) =>
q.key === key ? { ...q, error: res.error || "上传失败", progress: 100 } : q
)
);
toast(res.error || "上传失败", "error");
}
} catch {
setQueue((prev) =>
prev.map((q) =>
q.key === key ? { ...q, error: "上传失败", progress: 100 } : q
)
);
toast("上传失败", "error");
}
}
if (added.length) {
onChange([...attachments, ...added]);
toast(
added.length === 1 ? "附件已上传" : `已上传 ${added.length} 个附件`,
"ok"
);
}
setQueue([]);
setUploading(false);
}, [attachments, disabled, limits, onChange, pending, uploading]);
const removeAttachment = async (id: number) => {
const prev = attachments;
onChange(prev.filter((a) => a.id !== id));
try {
await apiDeletePostFile(id);
} catch {
onChange(prev);
toast("移除失败", "error");
}
};
const setAttPrice = (id: number, price: number) => {
const p = Math.max(0, Math.min(100000, Math.floor(price) || 0));
onChange(
attachments.map((a) => (a.id === id ? { ...a, price_points: p } : a))
);
const prevSnapshot = attachments;
const prevPrice = prevSnapshot.find((a) => a.id === id)?.price_points ?? 0;
const timers = priceTimers.current;
const old = timers.get(id);
if (old) clearTimeout(old);
timers.set(
id,
setTimeout(async () => {
try {
await apiUpdatePostFilePrice(id, p);
} catch {
onChange(
prevSnapshot.map((a) =>
a.id === id ? { ...a, price_points: prevPrice } : a
)
);
toast("定价保存失败", "error");
}
}, 400)
);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files?.length) stageFiles(e.dataTransfer.files);
};
const sectionProps = {
ref: sectionRef as React.RefObject<HTMLElement>,
className: "j13-compose-section j13-compose-attach-section",
"aria-labelledby": titleId,
id: "j13-compose-attachments",
};
const pickDisabled = disabled || uploading;
const canUpload = pending.length > 0 && !uploading && !disabled;
return (
<section {...sectionProps}>
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2 id={titleId} className="j13-compose-section-title j13-compose-attach-title !mb-0">
<Paperclip size={14} aria-hidden />
附件
<span className="j13-compose-attach-badge">
{attachments.length}/{limits.maxCount}
</span>
</h2>
</div>
<div
ref={dropRef}
className={`j13-compose-drop${dragOver ? " is-over" : ""}${
pickDisabled ? " is-disabled" : ""
}`}
onDragEnter={(e) => {
e.preventDefault();
if (!pickDisabled) setDragOver(true);
}}
onDragOver={(e) => {
e.preventDefault();
if (!pickDisabled) setDragOver(true);
}}
onDragLeave={(e) => {
if (!dropRef.current?.contains(e.relatedTarget as Node)) setDragOver(false);
}}
onDrop={onDrop}
onClick={() => {
if (!pickDisabled) fileRef.current?.click();
}}
role="button"
tabIndex={pickDisabled ? -1 : 0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!pickDisabled) fileRef.current?.click();
}
}}
aria-label="选择附件:拖拽或点击添加待传文件"
>
<Upload size={22} className="j13-compose-drop-icon" aria-hidden />
<p className="j13-compose-drop-lead">
{dragOver ? "放开以加入待传列表" : "拖到此处或点击选择文件"}
</p>
<p className="j13-compose-drop-hint">{hintLine}</p>
<p className="j13-compose-drop-hint">选好后会在下方出现醒目的「上传」按钮 · 可设积分下载</p>
<input
ref={fileRef}
type="file"
className="hidden"
multiple
accept={accept}
disabled={pickDisabled}
onChange={(e) => {
if (e.target.files?.length) stageFiles(e.target.files);
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
{pending.length > 0 ? (
<div className="j13-compose-att-pending" role="region" aria-label="待上传附件">
<div className="j13-compose-att-pending-head">
<p className="j13-compose-att-pending-title">
已选 {pending.length} 个文件 · 尚未上传
</p>
<button
type="button"
className="btn btn-line btn-sm"
disabled={uploading || disabled}
onClick={clearPending}
>
清空
</button>
</div>
<ul className="j13-compose-att-queue" aria-label="待上传列表">
{pending.map((p) => (
<li key={p.key} className="j13-compose-att-card">
<span className="j13-att-type" data-kind={fileKind(p.name, p.file.type || "")} aria-hidden>
{fileExtBadge(p.name)}
</span>
<div className="min-w-0 flex-1">
<div className="j13-compose-att-name truncate">{p.name}</div>
<div className="meta text-[12px]">{formatSize(p.size)}</div>
</div>
<button
type="button"
className="btn btn-line btn-sm !px-2"
data-tip="从待传列表移除"
aria-label={`移除待传 ${p.name}`}
disabled={uploading}
onClick={() => removePending(p.key)}
>
<X size={14} />
</button>
</li>
))}
</ul>
<button
type="button"
className="btn j13-compose-att-upload-btn"
disabled={!canUpload}
onClick={() => void uploadPending()}
>
<Upload size={16} aria-hidden />
上传 {pending.length} 个文件
</button>
</div>
) : null}
{queue.length > 0 ? (
<ul className="j13-compose-att-queue" aria-label="上传队列">
{queue.map((q) => (
<li key={q.key} className={`j13-compose-att-card${q.error ? " is-error" : ""}`}>
<span className="j13-att-type" data-kind="file" aria-hidden>
…
</span>
<div className="min-w-0 flex-1">
<div className="j13-compose-att-name truncate">{q.name}</div>
<div className="meta text-[12px]">
{q.error || formatSize(q.size)}
{uploading && !q.error ? (
<Loader2 size={12} className="inline ml-1.5 animate-spin" />
) : null}
</div>
<div className="j13-compose-att-bar" aria-hidden>
<span style={{ width: `${q.progress}%` }} />
</div>
</div>
</li>
))}
</ul>
) : null}
{attachments.length === 0 && pending.length === 0 && queue.length === 0 ? (
<p className="meta text-[13px] py-1">暂无附件 · 选择文件后点上传</p>
) : attachments.length > 0 ? (
<ul className="j13-compose-att-list">
{attachments.map((a) => (
<li key={a.id} className="j13-compose-att-card">
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
{fileExtBadge(a.name)}
</span>
<div className="min-w-0 flex-1">
<div className="j13-compose-att-name truncate">{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${
a.price_points > 0 ? " j13-compose-att-paid" : ""
}`}
>
积分
<input
type="number"
min={0}
max={100000}
value={a.price_points}
disabled={disabled}
onChange={(e) => setAttPrice(a.id, Number(e.target.value))}
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
data-tip="0 = 免费下载"
aria-label={`${a.name} 下载积分,0 为免费`}
onClick={(e) => e.stopPropagation()}
/>
</label>
<button
type="button"
className="btn btn-line btn-sm !px-2"
data-tip="移除附件"
aria-label="移除附件"
disabled={disabled}
onClick={() => void removeAttachment(a.id)}
>
<X size={14} />
</button>
</li>
))}
</ul>
) : null}
</section>
);
}
/** 底栏「附件 N/M」入口 */
export function ComposeAttachAction({
count,
max,
onClick,
children,
}: {
count: number;
max: number;
onClick: () => void;
children?: ReactNode;
}) {
return (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1.5"
onClick={onClick}
data-tip="跳转到附件区"
>
<Paperclip size={14} />
附件 {count}/{max}
{children}
</button>
);
}

View File

@@ -47,6 +47,8 @@ type MarkdownEditorProps = {
disabled?: boolean;
onSubmitShortcut?: () => void;
id?: string;
/** 正文插图体积上限(MB),缺省 5 */
imageMaxMB?: number;
};
type EditorMode = "split" | "write" | "preview";
@@ -107,6 +109,7 @@ export default function MarkdownEditor({
disabled,
onSubmitShortcut,
id,
imageMaxMB = 5,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
@@ -227,8 +230,9 @@ export default function MarkdownEditor({
toast("仅支持 JPEG / PNG / WebP", "error");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast("图片不能超过 5MB", "error");
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
if (file.size > maxBytes) {
toast(`图片不能超过 ${imageMaxMB}MB`, "error");
return;
}
setUploading(true);

View File

@@ -9,6 +9,7 @@ import {
postAttachmentDownloadURL,
type PostAttachment,
} from "@/lib/api";
import { fileExtBadge, fileKind } from "@/lib/fileKind";
import { toast } from "@/lib/toast";
import Modal from "@/components/Modal";
@@ -22,52 +23,6 @@ function formatPoints(n: number) {
return n.toLocaleString("zh-CN");
}
function fileExt(name: string) {
const i = name.lastIndexOf(".");
if (i <= 0 || i === name.length - 1) return "FILE";
const ext = name.slice(i + 1).replace(/[^a-zA-Z0-9]/g, "");
if (!ext || ext.length > 5) return "FILE";
return ext.toUpperCase();
}
function fileKind(name: string, mime: string): "image" | "archive" | "doc" | "media" | "file" {
const m = (mime || "").toLowerCase();
const ext = (name.split(".").pop() || "").toLowerCase();
if (
m.startsWith("image/") ||
["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "ico", "heic"].includes(ext)
) {
return "image";
}
if (
m.includes("zip") ||
m.includes("rar") ||
m.includes("7z") ||
m.includes("compress") ||
["zip", "rar", "7z", "tar", "gz", "bz2", "xz"].includes(ext)
) {
return "archive";
}
if (
m.startsWith("audio/") ||
m.startsWith("video/") ||
["mp3", "wav", "flac", "aac", "ogg", "mp4", "mkv", "webm", "mov", "avi"].includes(ext)
) {
return "media";
}
if (
m.includes("pdf") ||
m.includes("word") ||
m.includes("text") ||
m.includes("sheet") ||
m.includes("excel") ||
["pdf", "doc", "docx", "txt", "md", "xls", "xlsx", "ppt", "pptx", "csv"].includes(ext)
) {
return "doc";
}
return "file";
}
/** 把已拿到的文件交给浏览器下载栏(付费首次须先 fetch 才能处理 402) */
function triggerBrowserDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
@@ -92,7 +47,7 @@ function AttachmentMeta({
return (
<>
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
{fileExt(a.name)}
{fileExtBadge(a.name)}
</span>
<span className="j13-att-main">
<span className="j13-att-name">{a.name}</span>
@@ -312,7 +267,7 @@ export default function PostAttachments({
data-kind={fileKind(pending.name, pending.mime)}
aria-hidden
>
{fileExt(pending.name)}
{fileExtBadge(pending.name)}
</span>
<div className="min-w-0">
<p className="j13-att-pay-name">{pending.name}</p>

View File

@@ -3,17 +3,16 @@
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 { X } from "lucide-react";
import {
apiCreatePost,
apiUpdatePost,
apiUploadPostFile,
apiDeletePostFile,
apiUpdatePostFilePrice,
apiGetPoints,
apiGetSettings,
fetchBoards,
type Board,
type PostAttachment,
DEFAULT_ATTACHMENT_EXTS,
} from "@/lib/api";
import {
POST_TYPES,
@@ -26,11 +25,15 @@ import {
import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor";
import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
import ComposeAttachments, {
ComposeAttachAction,
} from "@/components/ComposeAttachments";
import DeadlinePicker, {
type DeadlineMode,
defaultCustomLocal,
localInputToRFC3339,
} from "@/components/DeadlinePicker";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
const TITLE_MAX = 256;
const DRAFT_KEY = "j13-compose-draft";
@@ -105,12 +108,6 @@ function writeDraft(d: ComposeDraft) {
}
}
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;
@@ -260,6 +257,14 @@ export default function PostComposer(props: PostComposerProps) {
isCreate ? [] : props.initialAttachments || []
);
const [fileBusy, setFileBusy] = useState(false);
const [pendingFileCount, setPendingFileCount] = useState(0);
const [attachLimits, setAttachLimits] = useState({
maxMB: 20,
maxCount: 10,
extLimit: true,
exts: [...DEFAULT_ATTACHMENT_EXTS],
imageMaxMB: 5,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
/** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */
@@ -268,13 +273,55 @@ export default function PostComposer(props: PostComposerProps) {
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipDraftSave = useRef(true);
const formRef = useRef<HTMLFormElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const attachSectionRef = useRef<HTMLElement | null>(null);
const actionsErrorRef = useRef<HTMLDivElement>(null);
const bountyFieldRef = useRef<HTMLDivElement>(null);
const pendingErrorFocus = useRef<"actions" | "bounty" | null>(null);
const pendingErrorFocus = useRef<"actions" | "bounty" | "attachments" | null>(null);
const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
useEffect(() => {
let cancelled = false;
apiGetSettings()
.then((s) => {
if (cancelled) return;
setAttachLimits({
maxMB: s.attachment_max_mb,
maxCount: s.attachment_max_count,
extLimit: s.attachment_ext_limit,
exts: s.attachment_exts,
imageMaxMB: s.image_max_mb,
});
})
.catch(() => {
/* 用默认限额 */
});
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
setAttachLimits((prev) => ({
maxMB:
typeof data.attachment_max_mb === "number" && data.attachment_max_mb >= 1
? data.attachment_max_mb
: prev.maxMB,
maxCount:
typeof data.attachment_max_count === "number" &&
data.attachment_max_count >= 1
? data.attachment_max_count
: prev.maxCount,
extLimit:
typeof data.attachment_ext_limit === "boolean"
? data.attachment_ext_limit
: prev.extLimit,
exts: Array.isArray(data.attachment_exts)
? data.attachment_exts
: prev.exts,
imageMaxMB:
typeof data.image_max_mb === "number" && data.image_max_mb >= 1
? data.image_max_mb
: prev.imageMaxMB,
}));
});
}, []);
useEffect(() => {
if (!isCreate) return;
let cancelled = false;
@@ -459,44 +506,28 @@ export default function PostComposer(props: PostComposerProps) {
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 scrollToAttachments = () => {
const el =
attachSectionRef.current ||
document.getElementById("j13-compose-attachments");
el?.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
const drop = el?.querySelector(".j13-compose-drop") as HTMLElement | null;
drop?.focus();
}, 320);
};
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 scrollToPendingUpload = () => {
const el =
attachSectionRef.current ||
document.getElementById("j13-compose-attachments");
el?.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
const btn = el?.querySelector(
".j13-compose-att-upload-btn"
) as HTMLButtonElement | null;
btn?.focus();
}, 320);
};
const bountyShort =
@@ -508,6 +539,7 @@ export default function PostComposer(props: PostComposerProps) {
const canSubmit =
!loading &&
!fileBusy &&
title.trim().length > 0 &&
content.trim().length > 0 &&
(isCreate ? boardsReady && boardId > 0 : true) &&
@@ -517,17 +549,24 @@ export default function PostComposer(props: PostComposerProps) {
(typeof bountyPoints === "number" && bountyPoints > 0)) &&
(postType !== "lottery" || lotteryPrizesReady);
/** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入 */
const reportSubmitError = useCallback((msg: string, focus: "actions" | "bounty" = "actions") => {
pendingErrorFocus.current = focus;
setError(msg);
toast(msg, "error");
}, []);
/** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入;待传附件滚到上传按钮 */
const reportSubmitError = useCallback(
(msg: string, focus: "actions" | "bounty" | "attachments" = "actions") => {
pendingErrorFocus.current = focus;
setError(msg);
toast(msg, "error");
},
[]
);
useEffect(() => {
if (!error) return;
const focus = pendingErrorFocus.current ?? "actions";
pendingErrorFocus.current = null;
if (focus === "attachments") {
scrollToPendingUpload();
return;
}
const el =
focus === "bounty" && bountyFieldRef.current
? bountyFieldRef.current
@@ -535,8 +574,21 @@ export default function PostComposer(props: PostComposerProps) {
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [error]);
// 待传清零后收起「尚未上传」提示(已上传或已移除)
useEffect(() => {
if (pendingFileCount > 0) return;
setError((prev) => (prev.includes("尚未上传") ? "" : prev));
}, [pendingFileCount]);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (pendingFileCount > 0) {
reportSubmitError(
`还有 ${pendingFileCount} 个附件尚未上传,请先上传或移除`,
"attachments"
);
return;
}
if (
isCreate &&
deadlineMode === "custom" &&
@@ -1177,6 +1229,7 @@ export default function PostComposer(props: PostComposerProps) {
onChange={setContent}
disabled={loading}
rows={18}
imageMaxMB={attachLimits.imageMaxMB}
placeholder={
"从这里开始写正文…\n\n工具栏可插入格式、代码块、链接、图片与隐藏内容(登录/回复/积分/密码可见)。支持粘贴与拖拽上传图片;默认双栏实时预览。"
}
@@ -1187,72 +1240,15 @@ export default function PostComposer(props: PostComposerProps) {
</div>
</section>
{/* 附件 */}
<section className="j13-compose-section" aria-label="附件">
<h2 className="j13-compose-section-title">附件</h2>
<div className="j13-compose-attach shrink-0">
<div className="flex items-center justify-between gap-3 mb-2">
<p className="meta text-[12.5px] !mb-0">单文件 ≤20MB,最多 10 个;可设免费或积分下载</p>
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1.5 shrink-0"
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>
{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 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${
a.price_points > 0 ? " j13-compose-att-paid" : ""
}`}
>
积分
<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 = 免费下载"
aria-label={`${a.name} 下载积分,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>
</section>
<ComposeAttachments
attachments={attachments}
onChange={setAttachments}
limits={attachLimits}
disabled={loading}
onBusyChange={setFileBusy}
onPendingChange={setPendingFileCount}
sectionRef={attachSectionRef}
/>
</div>
<div className="j13-compose-actions">
@@ -1272,6 +1268,11 @@ export default function PostComposer(props: PostComposerProps) {
</p>
)}
<div className="j13-compose-actions-end">
<ComposeAttachAction
count={attachments.length}
max={attachLimits.maxCount}
onClick={scrollToAttachments}
/>
{isCreate ? (
<Link href={cancelHref} className="btn btn-line !py-3">
取消
@@ -1294,9 +1295,11 @@ export default function PostComposer(props: PostComposerProps) {
? isCreate
? "发布中…"
: "保存中…"
: isCreate
? "发布帖子"
: "保存修改"}
: fileBusy
? "上传中…"
: isCreate
? "发布帖子"
: "保存修改"}
</button>
</div>
</div>

View File

@@ -0,0 +1,244 @@
"use client";
import { Plus, RotateCcw, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { DEFAULT_ATTACHMENT_EXTS } from "@/lib/api";
import ConfirmDialog from "@/components/ConfirmDialog";
/** 分组一键加入(只补缺,不删已有) */
export const ATTACHMENT_EXT_GROUPS: { id: string; label: string; exts: string[] }[] = [
{
id: "doc",
label: "文档",
exts: ["pdf", "txt", "md", "csv", "json", "doc", "docx", "xls", "xlsx", "ppt", "pptx"],
},
{
id: "archive",
label: "压缩包",
exts: ["zip", "7z", "rar", "tar", "gz", "tgz"],
},
{
id: "image",
label: "图片",
exts: ["jpg", "jpeg", "png", "webp", "gif"],
},
{
id: "media",
label: "音视频",
exts: ["mp3", "wav", "ogg", "mp4", "webm"],
},
{
id: "install",
label: "安装包",
exts: ["exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll"],
},
{
id: "web",
label: "网页与脚本",
exts: ["html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh"],
},
];
function parseInputTokens(raw: string): string[] {
return raw
.split(/[,,;\s]+/)
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
}
function isValidExt(e: string): boolean {
return /^[a-z0-9]{1,16}$/.test(e);
}
/**
* 附件扩展名芯片编辑器:增删、批量输入、分组补缺、恢复默认。
* 列表变更需显式保存(由父级 dirty + 保存按钮控制)。
*/
export default function AdminExtChipEditor({
value,
onChange,
disabled,
maxCount = 80,
}: {
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
maxCount?: number;
}) {
const [draft, setDraft] = useState("");
const [hint, setHint] = useState("");
const [confirmReset, setConfirmReset] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const setHintBrief = useCallback((msg: string) => {
setHint(msg);
window.setTimeout(() => setHint(""), 2200);
}, []);
const addExts = useCallback(
(raw: string[]) => {
if (disabled) return;
const next = [...value];
const seen = new Set(next);
let added = 0;
let invalid = 0;
for (const token of raw) {
if (!isValidExt(token)) {
invalid++;
continue;
}
if (seen.has(token)) continue;
if (next.length >= maxCount) {
setHintBrief(`最多 ${maxCount} 个扩展名`);
break;
}
seen.add(token);
next.push(token);
added++;
}
if (invalid && !added) setHintBrief("扩展名仅允许字母与数字");
else if (invalid) setHintBrief(`已加入 ${added} 个,跳过非法项`);
else if (!added && raw.length) setHintBrief("已在列表中");
if (added) onChange(next);
setDraft("");
},
[disabled, maxCount, onChange, setHintBrief, value]
);
const removeAt = (idx: number) => {
if (disabled) return;
onChange(value.filter((_, i) => i !== idx));
};
const mergeGroup = (exts: string[]) => {
if (disabled) return;
const seen = new Set(value);
const next = [...value];
for (const e of exts) {
if (seen.has(e)) continue;
if (next.length >= maxCount) break;
seen.add(e);
next.push(e);
}
onChange(next);
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addExts(parseInputTokens(draft));
return;
}
if (e.key === "Backspace" && draft === "" && value.length > 0) {
e.preventDefault();
removeAt(value.length - 1);
}
};
const emptyWarn = value.length === 0;
const groupButtons = useMemo(
() =>
ATTACHMENT_EXT_GROUPS.map((g) => (
<button
key={g.id}
type="button"
className="btn btn-line btn-sm !py-1 !px-2 text-[12px]"
disabled={disabled}
onClick={() => mergeGroup(g.exts)}
>
+ {g.label}
</button>
)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[disabled, value]
);
return (
<div className={`j13-ext-editor${disabled ? " is-disabled" : ""}`}>
{emptyWarn ? (
<p className="j13-ext-warn" role="status">
当前列表为空:开启限制时将无法上传任何附件
</p>
) : null}
<div className="j13-ext-groups" aria-label="分组一键加入">
{groupButtons}
<button
type="button"
className="btn btn-line btn-sm !py-1 !px-2 text-[12px] inline-flex items-center gap-1"
disabled={disabled}
onClick={() => setConfirmReset(true)}
>
<RotateCcw size={12} />
恢复默认
</button>
</div>
<div
className="j13-ext-wall"
onClick={() => inputRef.current?.focus()}
role="group"
aria-label="扩展名列表"
>
{value.map((ext, idx) => (
<span key={ext} className="j13-ext-chip">
.{ext}
<button
type="button"
className="j13-ext-chip-x"
disabled={disabled}
aria-label={`移除 ${ext}`}
onClick={(e) => {
e.stopPropagation();
removeAt(idx);
}}
>
<X size={12} />
</button>
</span>
))}
<span className="j13-ext-input-wrap">
<Plus size={14} className="opacity-40 shrink-0" aria-hidden />
<input
ref={inputRef}
type="text"
className="j13-ext-input"
value={draft}
disabled={disabled}
placeholder="输入扩展名,Enter 添加"
aria-label="添加扩展名"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => {
if (draft.trim()) addExts(parseInputTokens(draft));
}}
/>
</span>
</div>
{hint ? (
<p className="meta text-[12px] mt-1.5" role="status">
{hint}
</p>
) : (
<p className="meta text-[12px] mt-1.5">
支持 <code className="text-[11px]">.msi</code> / <code className="text-[11px]">msi</code>{" "}
或逗号批量;最多 {maxCount} 个
</p>
)}
<ConfirmDialog
open={confirmReset}
title="恢复默认扩展名"
message="将用站点默认列表覆盖当前编辑内容(需再点保存才会生效)。"
confirmLabel="恢复默认"
onCancel={() => setConfirmReset(false)}
onConfirm={() => {
onChange([...DEFAULT_ATTACHMENT_EXTS]);
setConfirmReset(false);
}}
/>
</div>
);
}

View File

@@ -14,3 +14,5 @@ export { default as AdminIconButton } from "./AdminIconButton";
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
export type { AdminStatusTone } from "./AdminStatusChip";
export { default as AdminShell } from "./AdminShell";
export { default as AdminExtChipEditor } from "./AdminExtChipEditor";
export { ATTACHMENT_EXT_GROUPS } from "./AdminExtChipEditor";

View File

@@ -616,8 +616,29 @@ export interface PublicSettings {
anim_chrome: boolean;
/** 帖内 Markdown 外链是否新标签打开;缺省 true */
post_link_new_tab: boolean;
/** 是否限制附件扩展名;缺省 true */
attachment_ext_limit: boolean;
/** 允许的扩展名(无点);缺省论坛向列表 */
attachment_exts: string[];
/** 附件单文件上限 MB;缺省 20 */
attachment_max_mb: number;
/** 每帖附件个数;缺省 10 */
attachment_max_count: number;
/** 正文插图上限 MB;缺省 5 */
image_max_mb: number;
}
/** 与后端 DefaultAttachmentExts 保持同步 */
export const DEFAULT_ATTACHMENT_EXTS: string[] = [
"pdf", "txt", "md", "csv", "json",
"doc", "docx", "xls", "xlsx", "ppt", "pptx",
"zip", "7z", "rar", "tar", "gz", "tgz",
"jpg", "jpeg", "png", "webp", "gif",
"mp3", "wav", "ogg", "mp4", "webm",
"exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll",
"html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh",
];
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
accent: "",
trust_reviewed_publish: true,
@@ -634,9 +655,45 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
anim_smooth_scroll: true,
anim_chrome: true,
post_link_new_tab: true,
attachment_ext_limit: true,
attachment_exts: [...DEFAULT_ATTACHMENT_EXTS],
attachment_max_mb: 20,
attachment_max_count: 10,
image_max_mb: 5,
};
function normalizeAttachmentExts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [...DEFAULT_ATTACHMENT_EXTS];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const e = String(item || "")
.trim()
.toLowerCase()
.replace(/^\./, "");
if (!/^[a-z0-9]{1,16}$/.test(e) || seen.has(e)) continue;
seen.add(e);
out.push(e);
if (out.length >= 80) break;
}
return out;
}
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
const maxMB =
typeof data?.attachment_max_mb === "number" && data.attachment_max_mb >= 1
? Math.round(data.attachment_max_mb)
: DEFAULT_PUBLIC_SETTINGS.attachment_max_mb;
const maxCount =
typeof data?.attachment_max_count === "number" &&
data.attachment_max_count >= 1 &&
data.attachment_max_count <= 20
? Math.round(data.attachment_max_count)
: DEFAULT_PUBLIC_SETTINGS.attachment_max_count;
const imageMB =
typeof data?.image_max_mb === "number" && data.image_max_mb >= 1
? Math.round(data.image_max_mb)
: DEFAULT_PUBLIC_SETTINGS.image_max_mb;
return {
accent: data?.accent ?? "",
trust_reviewed_publish: data?.trust_reviewed_publish !== false,
@@ -664,6 +721,14 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
anim_smooth_scroll: data?.anim_smooth_scroll !== false,
anim_chrome: data?.anim_chrome !== false,
post_link_new_tab: data?.post_link_new_tab !== false,
attachment_ext_limit: data?.attachment_ext_limit !== false,
attachment_exts:
data?.attachment_exts !== undefined
? normalizeAttachmentExts(data.attachment_exts)
: [...DEFAULT_ATTACHMENT_EXTS],
attachment_max_mb: maxMB,
attachment_max_count: maxCount,
image_max_mb: imageMB,
};
}
@@ -1042,7 +1107,20 @@ export async function apiUploadPostFile(
headers: clientHeaders(),
body: fd,
});
return res.json();
const ct = res.headers.get("content-type") || "";
if (!ct.includes("application/json")) {
if (res.status === 413) return { error: "附件过大" };
if (res.status >= 500) return { error: "上传失败,请稍后重试" };
return { error: "上传失败" };
}
const data = (await res.json().catch(() => ({}))) as {
attachment?: PostAttachment;
error?: string;
};
if (!res.ok) {
return { error: data.error || (res.status === 413 ? "附件过大" : "上传失败") };
}
return data;
}
export async function apiDeletePostFile(id: number) {
@@ -1272,7 +1350,21 @@ export async function apiUploadPostImage(
headers: clientHeaders(),
body: form,
});
return res.json();
const ct = res.headers.get("content-type") || "";
if (!ct.includes("application/json")) {
if (res.status === 413) return { error: "图片过大" };
if (res.status >= 500) return { error: "上传失败,请稍后重试" };
return { error: "上传失败" };
}
const data = (await res.json().catch(() => ({}))) as {
url?: string;
attachment?: MediaAttachment;
error?: string;
};
if (!res.ok) {
return { error: data.error || (res.status === 413 ? "图片过大" : "上传失败") };
}
return data;
}
// 选用一张本人历史上传的头像
@@ -1804,6 +1896,11 @@ export type UpdateSiteSettingsBody = {
anim_smooth_scroll?: boolean;
anim_chrome?: boolean;
post_link_new_tab?: boolean;
attachment_ext_limit?: boolean;
attachment_exts?: string[];
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色

49
frontend/lib/fileKind.ts Normal file
View File

@@ -0,0 +1,49 @@
/** 附件类型徽章分类(详情页与发帖编辑共用) */
export type FileKind = "image" | "archive" | "doc" | "media" | "file";
export function fileKind(name: string, mime: string): FileKind {
const m = (mime || "").toLowerCase();
const ext = (name.split(".").pop() || "").toLowerCase();
if (
m.startsWith("image/") ||
["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "ico", "heic"].includes(ext)
) {
return "image";
}
if (
m.includes("zip") ||
m.includes("rar") ||
m.includes("7z") ||
m.includes("compress") ||
["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz"].includes(ext)
) {
return "archive";
}
if (
m.startsWith("audio/") ||
m.startsWith("video/") ||
["mp3", "wav", "flac", "aac", "ogg", "mp4", "mkv", "webm", "mov", "avi"].includes(ext)
) {
return "media";
}
if (
m.includes("pdf") ||
m.includes("word") ||
m.includes("text") ||
m.includes("sheet") ||
m.includes("excel") ||
["pdf", "doc", "docx", "txt", "md", "xls", "xlsx", "ppt", "pptx", "csv", "json"].includes(ext)
) {
return "doc";
}
return "file";
}
export function fileExtBadge(name: string): string {
const i = name.lastIndexOf(".");
if (i <= 0 || i === name.length - 1) return "FILE";
const ext = name.slice(i + 1).replace(/[^a-zA-Z0-9]/g, "");
if (!ext || ext.length > 5) return "FILE";
return ext.toUpperCase();
}

58
frontend/lib/postFile.ts Normal file
View File

@@ -0,0 +1,58 @@
/** 帖子附件客户端校验(与后端 setting / fileallow 对齐;安全边界仍在 Go) */
export const FILE_MAX_MB_DEFAULT = 20;
export const FILE_MAX_COUNT_DEFAULT = 10;
export function normalizeExt(nameOrExt: string): string {
const base = nameOrExt.includes("/")
? nameOrExt.split(/[/\\]/).pop() || ""
: nameOrExt;
const i = base.lastIndexOf(".");
const ext = (i >= 0 ? base.slice(i + 1) : base).trim().toLowerCase();
return ext.replace(/[^a-z0-9]/g, "").slice(0, 16);
}
export function buildAcceptAttr(exts: string[], limit: boolean): string | undefined {
if (!limit) return undefined;
if (!exts.length) return undefined;
return exts.map((e) => `.${e}`).join(",");
}
export function isAllowedPostFileName(
name: string,
opts: { limit: boolean; exts: string[] }
): boolean {
if (!opts.limit) return true;
const ext = normalizeExt(name);
if (!ext) return false;
return opts.exts.includes(ext);
}
export function formatMB(n: number): string {
return `${n}MB`;
}
export type PostFileRejectReason = "size" | "type" | "count" | null;
export function rejectPostFile(
file: File,
opts: {
limit: boolean;
exts: string[];
maxMB: number;
maxCount: number;
currentCount: number;
}
): { reason: PostFileRejectReason; message?: string } {
if (opts.currentCount >= opts.maxCount) {
return { reason: "count", message: `最多 ${opts.maxCount} 个附件` };
}
const maxBytes = opts.maxMB * 1024 * 1024;
if (file.size > maxBytes) {
return { reason: "size", message: `附件不能超过 ${opts.maxMB}MB` };
}
if (!isAllowedPostFileName(file.name, opts)) {
return { reason: "type", message: "不支持该附件格式" };
}
return { reason: null };
}

View File

@@ -54,6 +54,11 @@ export interface RtSettingsData {
anim_smooth_scroll?: boolean;
anim_chrome?: boolean;
post_link_new_tab?: boolean;
attachment_ext_limit?: boolean;
attachment_exts?: string[];
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}
// 群聊新消息:data 为落库的 ChatMessage(含 sender)

View File

@@ -11,6 +11,10 @@ const nextConfig: NextConfig = {
{ protocol: "http", hostname: "localhost" },
],
},
experimental: {
// rewrite/proxy 默认只缓冲 10MB;附件上限由 Go 站点设置决定,此处留足余量(含 multipart 开销)
proxyClientMaxBodySize: "512mb",
},
async rewrites() {
return [
{