后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor <cursoragent@cursor.com>
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|