+
+
+
+
+ 附件单文件上限
+ >
+ }
+ description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。"
+ control={
+ {
+ setAttMaxMB(n);
+ scheduleLimitSave({ attachment_max_mb: n });
+ }}
+ />
+ }
+ />
+ {attMaxMB >= 50 ? (
+
+ 当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。
+
+ ) : null}
+
+
+ 每帖附件个数
+ >
+ }
+ description="单帖(含草稿)最多可绑定的附件数量。"
+ control={
+ {
+ setAttMaxCount(n);
+ scheduleLimitSave({ attachment_max_count: n });
+ }}
+ />
+ }
+ />
+
+
+ 正文插图上限
+ >
+ }
+ description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。"
+ control={
+ {
+ setImageMaxMB(n);
+ scheduleLimitSave({ image_max_mb: n });
+ }}
+ />
+ }
+ />
+ {imageMaxMB >= 10 ? (
+
+ 插图 ≥10MB:解码头部更耗内存,建议保持较小值。
+
+ ) : null}
+
+
+ 限制附件扩展名
+ >
+ }
+ description={
+ extLimit
+ ? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。"
+ : "任意格式可传,仍受体积上限与下载沙箱约束。"
+ }
+ control={
+ void toggleExtLimit()}
+ label="限制附件扩展名"
+ />
+ }
+ />
+
+
+
+ 允许的扩展名
+
+
+
+
+
+
+
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 655785a..711bafc 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -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;
diff --git a/frontend/components/ComposeAttachments.tsx b/frontend/components/ComposeAttachments.tsx
new file mode 100644
index 0000000..f1acc3c
--- /dev/null
+++ b/frontend/components/ComposeAttachments.tsx
@@ -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
;
+};
+
+export default function ComposeAttachments({
+ attachments,
+ onChange,
+ limits,
+ disabled,
+ onBusyChange,
+ onPendingChange,
+ sectionRef,
+}: Props) {
+ const fileRef = useRef(null);
+ const dropRef = useRef(null);
+ const [dragOver, setDragOver] = useState(false);
+ const [pending, setPending] = useState([]);
+ const [queue, setQueue] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const priceTimers = useRef
@@ -1272,6 +1268,11 @@ export default function PostComposer(props: PostComposerProps) {
)}
+
{isCreate ? (
取消
@@ -1294,9 +1295,11 @@ export default function PostComposer(props: PostComposerProps) {
? isCreate
? "发布中…"
: "保存中…"
- : isCreate
- ? "发布帖子"
- : "保存修改"}
+ : fileBusy
+ ? "上传中…"
+ : isCreate
+ ? "发布帖子"
+ : "保存修改"}
diff --git a/frontend/components/admin/AdminExtChipEditor.tsx b/frontend/components/admin/AdminExtChipEditor.tsx
new file mode 100644
index 0000000..f26467f
--- /dev/null
+++ b/frontend/components/admin/AdminExtChipEditor.tsx
@@ -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
(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) => {
+ 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) => (
+
+ )),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [disabled, value]
+ );
+
+ return (
+
+ {emptyWarn ? (
+
+ 当前列表为空:开启限制时将无法上传任何附件
+
+ ) : null}
+
+
+ {groupButtons}
+
+
+
+
inputRef.current?.focus()}
+ role="group"
+ aria-label="扩展名列表"
+ >
+ {value.map((ext, idx) => (
+
+ .{ext}
+
+
+ ))}
+
+
+ setDraft(e.target.value)}
+ onKeyDown={onKeyDown}
+ onBlur={() => {
+ if (draft.trim()) addExts(parseInputTokens(draft));
+ }}
+ />
+
+
+
+ {hint ? (
+
+ {hint}
+
+ ) : (
+
+ 支持 .msi / msi{" "}
+ 或逗号批量;最多 {maxCount} 个
+
+ )}
+
+
setConfirmReset(false)}
+ onConfirm={() => {
+ onChange([...DEFAULT_ATTACHMENT_EXTS]);
+ setConfirmReset(false);
+ }}
+ />
+
+ );
+}
diff --git a/frontend/components/admin/index.ts b/frontend/components/admin/index.ts
index 39a6988..3c6eed6 100644
--- a/frontend/components/admin/index.ts
+++ b/frontend/components/admin/index.ts
@@ -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";
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 0689ae8..fc59a09 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -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();
+ 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 | 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 | 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 传空串恢复默认主题色
diff --git a/frontend/lib/fileKind.ts b/frontend/lib/fileKind.ts
new file mode 100644
index 0000000..5af17f9
--- /dev/null
+++ b/frontend/lib/fileKind.ts
@@ -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();
+}
diff --git a/frontend/lib/postFile.ts b/frontend/lib/postFile.ts
new file mode 100644
index 0000000..413bbf2
--- /dev/null
+++ b/frontend/lib/postFile.ts
@@ -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 };
+}
diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts
index 3c8dbff..a8c5ed3 100644
--- a/frontend/lib/realtime.ts
+++ b/frontend/lib/realtime.ts
@@ -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)
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 3d2e449..b89de0e 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -11,6 +11,10 @@ const nextConfig: NextConfig = {
{ protocol: "http", hostname: "localhost" },
],
},
+ experimental: {
+ // rewrite/proxy 默认只缓冲 10MB;附件上限由 Go 站点设置决定,此处留足余量(含 multipart 开销)
+ proxyClientMaxBodySize: "512mb",
+ },
async rewrites() {
return [
{