Files
jiang13-bbs/frontend/components/ComposeAttachments.tsx
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

492 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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.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>
);
}