"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>>(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, 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 (

附件 {attachments.length}/{limits.maxCount}

{ 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="选择附件:拖拽或点击添加待传文件" >

{dragOver ? "放开以加入待传列表" : "拖到此处或点击选择文件"}

{hintLine}

选好后会在下方出现醒目的「上传」按钮 · 可设积分下载

{ if (e.target.files?.length) stageFiles(e.target.files); }} onClick={(e) => e.stopPropagation()} />
{pending.length > 0 ? (

已选 {pending.length} 个文件 · 尚未上传

    {pending.map((p) => (
  • {fileExtBadge(p.name)}
    {p.name}
    {formatSize(p.size)}
  • ))}
) : null} {queue.length > 0 ? (
    {queue.map((q) => (
  • …
    {q.name}
    {q.error || formatSize(q.size)} {uploading && !q.error ? ( ) : null}
  • ))}
) : null} {attachments.length === 0 && pending.length === 0 && queue.length === 0 ? (

暂无附件 · 选择文件后点上传

) : attachments.length > 0 ? (
    {attachments.map((a) => (
  • {fileExtBadge(a.name)}
    {a.name}
    {formatSize(a.size)}
  • ))}
) : null}
); } /** 底栏「附件 N/M」入口 */ export function ComposeAttachAction({ count, max, onClick, children, }: { count: number; max: number; onClick: () => void; children?: ReactNode; }) { return ( ); }