import { useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Check, RefreshCw, Search, X } from "lucide-react"; import { HttpError } from "../../shared/api/errors"; import type { AttachmentType as AttachmentTypeCode } from "../../shared/api/enums"; import { notifyContentChanged } from "../../shared/state/contentRefresh"; import { withDocumentPassword } from "../../shared/state/documentPassword"; import { DocumentPasswordAction } from "../../shared/api/enums"; import { attachmentApi } from "../attachments/attachmentApi"; import { useAttachmentList } from "../attachments/attachmentState"; import { documentApi } from "../documents/documentApi"; import { invalidateDetail } from "../documents/documentState"; import type { DocumentSummary } from "../documents/documentTypes"; import { useDictionaries } from "../dictionaries/dictionaryContext"; import { bindingApi } from "./bindingApi"; import { dedupeAttachmentIds, describeBindResult, toggleAttachmentSelection, } from "./bindingState"; const NAVY = "var(--dms-deep)"; const BLUE = "var(--dms-bright)"; const WHITE = "#FFFFFF"; const PALE = "var(--dms-canvas)"; interface AttachmentBindingModalProps { mainPlan: DocumentSummary; /** 附件类型筛选预置值(管理页附件 Tab 页"挂载"入口按当前页签类型预过滤)。 */ initialAttachmentType?: AttachmentTypeCode; /** 软排序偏好类型:该类型条目排在前面、其余类型排在其后(不过滤)。 * 配套方案Tab入口使用:库内配套方案优先展示,其余类型仍可选择挂载。 */ preferredAttachmentType?: AttachmentTypeCode; onClose: () => void; onChanged: () => void; } interface MountedState { ids: Set; loading: boolean; error: HttpError | null; } /** 软排序模式的全量共享资料库(跨页拉齐后整表展示,无分页 UI)。 */ interface LibraryState { items: DocumentSummary[]; loading: boolean; error: HttpError | null; } function asHttpError(error: unknown, message: string): HttpError { return error instanceof HttpError ? error : new HttpError({ httpStatus: 0, code: "INTERNAL_ERROR", message, details: null, requestId: "unknown", cause: error, }); } export function AttachmentBindingModal({ mainPlan, initialAttachmentType, preferredAttachmentType, onClose, onChanged, }: AttachmentBindingModalProps) { const dictionaries = useDictionaries(); const [keyword, setKeyword] = useState(""); const [attachmentType, setAttachmentType] = useState(initialAttachmentType ?? ""); const [fileExtension, setFileExtension] = useState(""); const [updatedFrom, setUpdatedFrom] = useState(""); const [updatedTo, setUpdatedTo] = useState(""); const [sortBy, setSortBy] = useState<"documentName" | "updatedAt">("updatedAt"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(1); const [selected, setSelected] = useState([]); const [mounted, setMounted] = useState({ ids: new Set(), loading: true, error: null, }); const [mountedRevision, setMountedRevision] = useState(0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(""); const mountedRequestVersion = useRef(0); // 筛选条件(分页模式与软排序全量模式共用;useMemo 稳定引用供 effect 依赖) const filters = useMemo( () => ({ keyword: keyword.trim() || undefined, attachmentType: attachmentType || undefined, fileExtension: fileExtension.trim() || undefined, updatedFrom: updatedFrom ? `${updatedFrom}T00:00:00.000Z` : undefined, updatedTo: updatedTo ? `${updatedTo}T23:59:59.999Z` : undefined, sortBy, sortDirection, }), [keyword, attachmentType, fileExtension, updatedFrom, updatedTo, sortBy, sortDirection], ); // 分页模式:服务端分页 10 条/页;软排序模式停用(改走下方全量拉取) const attachments = useAttachmentList( { ...filters, page, pageSize: 10 }, !preferredAttachmentType, ); // 软排序模式:整表展示全量共享资料库。契约上限 pageSize≤100(单请求 200 会被 // 400 INVALID_ARGUMENT 拒绝),按 100/页循环翻页拉完,与已挂载列表同款循环。 const [library, setLibrary] = useState({ items: [], loading: true, error: null, }); const libraryVersion = useRef(0); useEffect(() => { if (!preferredAttachmentType) return; const version = ++libraryVersion.current; const controller = new AbortController(); setLibrary((current) => ({ ...current, loading: true, error: null })); void (async () => { const items: DocumentSummary[] = []; let currentPage = 1; let totalPages = 1; do { const result = await attachmentApi.list( { ...filters, page: currentPage, pageSize: 100 }, controller.signal, ); items.push(...result.items); totalPages = result.totalPages; currentPage += 1; } while (currentPage <= totalPages); if (!controller.signal.aborted && version === libraryVersion.current) { setLibrary({ items, loading: false, error: null }); } })().catch((loadError: unknown) => { if (!controller.signal.aborted && version === libraryVersion.current) { setLibrary({ items: [], loading: false, error: asHttpError(loadError, "共享资料库加载失败"), }); } }); return () => controller.abort(); }, [preferredAttachmentType, filters]); // 软排序:偏好类型排前(稳定排序保留服务端排序的相对次序),其余类型排后 const orderedItems = useMemo(() => { const items = preferredAttachmentType ? library.items : (attachments.data?.items ?? []); if (!preferredAttachmentType) return items; return [...items].sort((left, right) => { const leftRank = left.attachmentType === preferredAttachmentType ? 0 : 1; const rightRank = right.attachmentType === preferredAttachmentType ? 0 : 1; return leftRank - rightRank; }); }, [attachments.data, library.items, preferredAttachmentType]); useEffect(() => { const version = ++mountedRequestVersion.current; const controller = new AbortController(); setMounted((current) => ({ ...current, loading: true, error: null })); void (async () => { const ids = new Set(); let currentPage = 1; let totalPages = 1; do { const result = await attachmentApi.mountedByMainPlan( mainPlan.id, { page: currentPage, pageSize: 100 }, controller.signal, ); result.items.forEach((item) => ids.add(item.id)); totalPages = result.totalPages; currentPage += 1; } while (currentPage <= totalPages); if (!controller.signal.aborted && version === mountedRequestVersion.current) { setMounted({ ids, loading: false, error: null }); } })().catch((loadError: unknown) => { if (!controller.signal.aborted && version === mountedRequestVersion.current) { setMounted({ ids: new Set(), loading: false, error: asHttpError(loadError, "已挂载附件加载失败"), }); } }); return () => controller.abort(); }, [mainPlan.id, mountedRevision]); const selectedCount = useMemo( () => selected.filter((id) => !mounted.ids.has(id)).length, [mounted.ids, selected], ); const handleSubmit = async () => { if (submitting) return; const attachmentIds = dedupeAttachmentIds(selected); if (attachmentIds.length === 0) { setNotice("请至少选择一个未挂载附件"); return; } setSubmitting(true); setError(null); setNotice(""); try { const latest = await documentApi.detail(mainPlan.id); const result = await withDocumentPassword( mainPlan.id, mainPlan.documentName, DocumentPasswordAction.BIND_ATTACHMENT, (password) => bindingApi.bind(mainPlan.id, { attachmentIds, mainPlanRowVersion: latest.rowVersion, }, undefined, password), ); setNotice(describeBindResult(result.data)); invalidateDetail("document", mainPlan.id); notifyContentChanged("documents", "attachments"); setMountedRevision((value) => value + 1); onChanged(); } catch (submitError) { const httpError = asHttpError(submitError, "挂载失败"); setError(httpError); if (httpError.code === "DATA_VERSION_CONFLICT") { setNotice("主案已被其他用户修改,已刷新最新挂载关系;请核对保留的选择后再次提交。"); invalidateDetail("document", mainPlan.id); notifyContentChanged("documents", "attachments"); setMountedRevision((value) => value + 1); onChanged(); } } finally { setSubmitting(false); } }; const loading = (preferredAttachmentType ? library.loading : attachments.loading) || mounted.loading; const loadError = (preferredAttachmentType ? library.error : attachments.error) ?? mounted.error; return (
挂载共享资料 主案:{mainPlan.documentName}
{ setFileExtension(event.target.value); setPage(1); }} placeholder="扩展名,如 docx" className="border px-3 text-base font-bold outline-none" />
{loading &&
正在加载附件和挂载关系…
} {loadError && !loading && (
{loadError.httpStatus === 403 ? "当前账号无权管理附件挂载" : loadError.message} {loadError.requestId &&
错误编号:{loadError.requestId}
}
)} {!loading && !loadError && orderedItems.length === 0 && (
暂无符合条件的配套资料
)} {!loading && !loadError && orderedItems.length > 0 && ( {["选择", "附件名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => )} {orderedItems.map((item) => { const alreadyMounted = mounted.ids.has(item.id); const checked = selected.includes(item.id); return ( ); })}
{heading}
setSelected((current) => toggleAttachmentSelection(current, item.id))} />
{item.documentName}
{dictionaries.label("attachmentTypes", item.attachmentType)} {item.fileExtension} {new Date(item.updatedAt).toLocaleString("zh-CN")} {alreadyMounted ? 已挂载 : 可挂载}
)}
{!preferredAttachmentType && attachments.data && attachments.data.totalPages > 1 && (
{page}/{attachments.data.totalPages}
)}
{error &&
{error.httpStatus === 403 ? "当前账号无挂载管理权限" : error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}
} {notice &&
{notice}
}
已选择 {selectedCount} 个未挂载附件;翻页和搜索不会清除选择。
); }