| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- 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<string>;
- 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<AttachmentTypeCode | "">(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<string[]>([]);
- const [mounted, setMounted] = useState<MountedState>({
- ids: new Set(),
- loading: true,
- error: null,
- });
- const [mountedRevision, setMountedRevision] = useState(0);
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState<HttpError | null>(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<LibraryState>({
- 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<string>();
- 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 (
- <div
- className="fixed inset-0 z-[80] flex items-center justify-center p-6"
- style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
- >
- <div
- className="w-[980px] max-w-full h-[82vh] flex flex-col shadow-2xl"
- style={{ background: WHITE }}
- >
- <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
- <div>
- <span className="font-black">挂载共享资料</span>
- <span className="ml-3 text-base opacity-75">主案:{mainPlan.documentName}</span>
- </div>
- <button onClick={onClose} disabled={submitting}><X size={18} /></button>
- </div>
- <div className="p-4 grid grid-cols-4 gap-2 border-b" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
- <label className="col-span-2 flex items-center gap-2 border px-3 h-9">
- <Search size={14} />
- <input
- value={keyword}
- onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
- placeholder="搜索附件名称、概述或标签"
- className="w-full bg-transparent outline-none text-base font-bold"
- />
- </label>
- <select
- value={attachmentType}
- onChange={(event) => { setAttachmentType(event.target.value as AttachmentTypeCode | ""); setPage(1); }}
- className="border px-2 text-base font-bold"
- >
- <option value="">全部附件类型</option>
- {dictionaries.options("attachmentTypes").map((option) => (
- <option key={option.code} value={option.code}>{option.label}</option>
- ))}
- </select>
- <input
- value={fileExtension}
- onChange={(event) => { setFileExtension(event.target.value); setPage(1); }}
- placeholder="扩展名,如 docx"
- className="border px-3 text-base font-bold outline-none"
- />
- <label className="text-base font-bold">更新起始
- <input type="date" value={updatedFrom} onChange={(event) => { setUpdatedFrom(event.target.value); setPage(1); }} className="block mt-1 border h-8 px-2 w-full" />
- </label>
- <label className="text-base font-bold">更新截止
- <input type="date" value={updatedTo} onChange={(event) => { setUpdatedTo(event.target.value); setPage(1); }} className="block mt-1 border h-8 px-2 w-full" />
- </label>
- <select value={sortBy} onChange={(event) => setSortBy(event.target.value as "documentName" | "updatedAt")} className="border px-2 text-base font-bold">
- <option value="updatedAt">按更新时间</option>
- <option value="documentName">按名称</option>
- </select>
- <select value={sortDirection} onChange={(event) => setSortDirection(event.target.value as "asc" | "desc")} className="border px-2 text-base font-bold">
- <option value="desc">降序</option>
- <option value="asc">升序</option>
- </select>
- </div>
- <div className="flex-1 overflow-auto">
- {loading && <div className="p-10 text-center font-bold" style={{ color: NAVY }}><RefreshCw size={16} className="inline mr-2 animate-spin" />正在加载附件和挂载关系…</div>}
- {loadError && !loading && (
- <div className="p-10 text-center font-bold" style={{ color: NAVY }}>
- <AlertCircle size={16} className="inline mr-2" />
- {loadError.httpStatus === 403 ? "当前账号无权管理附件挂载" : loadError.message}
- {loadError.requestId && <div className="text-sm mt-2">错误编号:{loadError.requestId}</div>}
- </div>
- )}
- {!loading && !loadError && orderedItems.length === 0 && (
- <div className="p-10 text-center text-sm font-bold opacity-55" style={{ color: NAVY }}>暂无符合条件的配套资料</div>
- )}
- {!loading && !loadError && orderedItems.length > 0 && (
- <table className="w-full border-collapse">
- <thead className="sticky top-0">
- <tr style={{ background: PALE, color: NAVY }}>
- {["选择", "附件名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-base font-black">{heading}</th>)}
- </tr>
- </thead>
- <tbody>
- {orderedItems.map((item) => {
- const alreadyMounted = mounted.ids.has(item.id);
- const checked = selected.includes(item.id);
- return (
- <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]" style={{ borderColor: "rgba(var(--dms-bright-rgb),.12)", color: NAVY }}>
- <td className="px-4 py-3">
- <input
- type="checkbox"
- checked={checked && !alreadyMounted}
- disabled={alreadyMounted || submitting}
- onChange={() => setSelected((current) => toggleAttachmentSelection(current, item.id))}
- />
- </td>
- <td className="px-4 py-3"><div className="text-base font-black">{item.documentName}</div></td>
- <td className="px-4 py-3 text-base font-bold">{dictionaries.label("attachmentTypes", item.attachmentType)}</td>
- <td className="px-4 py-3 text-base font-bold">{item.fileExtension}</td>
- <td className="px-4 py-3 text-base font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>
- <td className="px-4 py-3">
- {alreadyMounted
- ? <span className="inline-flex items-center gap-1 text-base font-black"><Check size={13} />已挂载</span>
- : <span className="text-base font-bold opacity-60">可挂载</span>}
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- )}
- </div>
- {!preferredAttachmentType && attachments.data && attachments.data.totalPages > 1 && (
- <div className="px-4 py-2 flex justify-end gap-4 text-base font-black" style={{ color: NAVY }}>
- <button disabled={page <= 1 || submitting} onClick={() => setPage((value) => value - 1)}>上一页</button>
- <span>{page}/{attachments.data.totalPages}</span>
- <button disabled={page >= attachments.data.totalPages || submitting} onClick={() => setPage((value) => value + 1)}>下一页</button>
- </div>
- )}
- <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
- {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{error.httpStatus === 403 ? "当前账号无挂载管理权限" : error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
- {notice && <div className="mb-2 text-base font-black">{notice}</div>}
- <div className="flex items-center">
- <span className="text-base font-bold">已选择 {selectedCount} 个未挂载附件;翻页和搜索不会清除选择。</span>
- <div className="ml-auto flex gap-2">
- <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
- <button onClick={() => void handleSubmit()} disabled={submitting || selected.length === 0 || !!loadError} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>
- {submitting ? "正在挂载…" : "确认挂载"}
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
- );
- }
|