AttachmentBindingModal.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { useEffect, useMemo, useRef, useState } from "react";
  2. import { AlertCircle, Check, RefreshCw, Search, X } from "lucide-react";
  3. import { HttpError } from "../../shared/api/errors";
  4. import type { AttachmentType as AttachmentTypeCode } from "../../shared/api/enums";
  5. import { notifyContentChanged } from "../../shared/state/contentRefresh";
  6. import { withDocumentPassword } from "../../shared/state/documentPassword";
  7. import { DocumentPasswordAction } from "../../shared/api/enums";
  8. import { attachmentApi } from "../attachments/attachmentApi";
  9. import { useAttachmentList } from "../attachments/attachmentState";
  10. import { documentApi } from "../documents/documentApi";
  11. import { invalidateDetail } from "../documents/documentState";
  12. import type { DocumentSummary } from "../documents/documentTypes";
  13. import { useDictionaries } from "../dictionaries/dictionaryContext";
  14. import { bindingApi } from "./bindingApi";
  15. import {
  16. dedupeAttachmentIds,
  17. describeBindResult,
  18. toggleAttachmentSelection,
  19. } from "./bindingState";
  20. const NAVY = "var(--dms-deep)";
  21. const BLUE = "var(--dms-bright)";
  22. const WHITE = "#FFFFFF";
  23. const PALE = "var(--dms-canvas)";
  24. interface AttachmentBindingModalProps {
  25. mainPlan: DocumentSummary;
  26. /** 附件类型筛选预置值(管理页附件 Tab 页"挂载"入口按当前页签类型预过滤)。 */
  27. initialAttachmentType?: AttachmentTypeCode;
  28. /** 软排序偏好类型:该类型条目排在前面、其余类型排在其后(不过滤)。
  29. * 配套方案Tab入口使用:库内配套方案优先展示,其余类型仍可选择挂载。 */
  30. preferredAttachmentType?: AttachmentTypeCode;
  31. onClose: () => void;
  32. onChanged: () => void;
  33. }
  34. interface MountedState {
  35. ids: Set<string>;
  36. loading: boolean;
  37. error: HttpError | null;
  38. }
  39. /** 软排序模式的全量共享资料库(跨页拉齐后整表展示,无分页 UI)。 */
  40. interface LibraryState {
  41. items: DocumentSummary[];
  42. loading: boolean;
  43. error: HttpError | null;
  44. }
  45. function asHttpError(error: unknown, message: string): HttpError {
  46. return error instanceof HttpError
  47. ? error
  48. : new HttpError({
  49. httpStatus: 0,
  50. code: "INTERNAL_ERROR",
  51. message,
  52. details: null,
  53. requestId: "unknown",
  54. cause: error,
  55. });
  56. }
  57. export function AttachmentBindingModal({
  58. mainPlan,
  59. initialAttachmentType,
  60. preferredAttachmentType,
  61. onClose,
  62. onChanged,
  63. }: AttachmentBindingModalProps) {
  64. const dictionaries = useDictionaries();
  65. const [keyword, setKeyword] = useState("");
  66. const [attachmentType, setAttachmentType] = useState<AttachmentTypeCode | "">(initialAttachmentType ?? "");
  67. const [fileExtension, setFileExtension] = useState("");
  68. const [updatedFrom, setUpdatedFrom] = useState("");
  69. const [updatedTo, setUpdatedTo] = useState("");
  70. const [sortBy, setSortBy] = useState<"documentName" | "updatedAt">("updatedAt");
  71. const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
  72. const [page, setPage] = useState(1);
  73. const [selected, setSelected] = useState<string[]>([]);
  74. const [mounted, setMounted] = useState<MountedState>({
  75. ids: new Set(),
  76. loading: true,
  77. error: null,
  78. });
  79. const [mountedRevision, setMountedRevision] = useState(0);
  80. const [submitting, setSubmitting] = useState(false);
  81. const [error, setError] = useState<HttpError | null>(null);
  82. const [notice, setNotice] = useState("");
  83. const mountedRequestVersion = useRef(0);
  84. // 筛选条件(分页模式与软排序全量模式共用;useMemo 稳定引用供 effect 依赖)
  85. const filters = useMemo(
  86. () => ({
  87. keyword: keyword.trim() || undefined,
  88. attachmentType: attachmentType || undefined,
  89. fileExtension: fileExtension.trim() || undefined,
  90. updatedFrom: updatedFrom ? `${updatedFrom}T00:00:00.000Z` : undefined,
  91. updatedTo: updatedTo ? `${updatedTo}T23:59:59.999Z` : undefined,
  92. sortBy,
  93. sortDirection,
  94. }),
  95. [keyword, attachmentType, fileExtension, updatedFrom, updatedTo, sortBy, sortDirection],
  96. );
  97. // 分页模式:服务端分页 10 条/页;软排序模式停用(改走下方全量拉取)
  98. const attachments = useAttachmentList(
  99. { ...filters, page, pageSize: 10 },
  100. !preferredAttachmentType,
  101. );
  102. // 软排序模式:整表展示全量共享资料库。契约上限 pageSize≤100(单请求 200 会被
  103. // 400 INVALID_ARGUMENT 拒绝),按 100/页循环翻页拉完,与已挂载列表同款循环。
  104. const [library, setLibrary] = useState<LibraryState>({
  105. items: [],
  106. loading: true,
  107. error: null,
  108. });
  109. const libraryVersion = useRef(0);
  110. useEffect(() => {
  111. if (!preferredAttachmentType) return;
  112. const version = ++libraryVersion.current;
  113. const controller = new AbortController();
  114. setLibrary((current) => ({ ...current, loading: true, error: null }));
  115. void (async () => {
  116. const items: DocumentSummary[] = [];
  117. let currentPage = 1;
  118. let totalPages = 1;
  119. do {
  120. const result = await attachmentApi.list(
  121. { ...filters, page: currentPage, pageSize: 100 },
  122. controller.signal,
  123. );
  124. items.push(...result.items);
  125. totalPages = result.totalPages;
  126. currentPage += 1;
  127. } while (currentPage <= totalPages);
  128. if (!controller.signal.aborted && version === libraryVersion.current) {
  129. setLibrary({ items, loading: false, error: null });
  130. }
  131. })().catch((loadError: unknown) => {
  132. if (!controller.signal.aborted && version === libraryVersion.current) {
  133. setLibrary({
  134. items: [],
  135. loading: false,
  136. error: asHttpError(loadError, "共享资料库加载失败"),
  137. });
  138. }
  139. });
  140. return () => controller.abort();
  141. }, [preferredAttachmentType, filters]);
  142. // 软排序:偏好类型排前(稳定排序保留服务端排序的相对次序),其余类型排后
  143. const orderedItems = useMemo(() => {
  144. const items = preferredAttachmentType
  145. ? library.items
  146. : (attachments.data?.items ?? []);
  147. if (!preferredAttachmentType) return items;
  148. return [...items].sort((left, right) => {
  149. const leftRank = left.attachmentType === preferredAttachmentType ? 0 : 1;
  150. const rightRank = right.attachmentType === preferredAttachmentType ? 0 : 1;
  151. return leftRank - rightRank;
  152. });
  153. }, [attachments.data, library.items, preferredAttachmentType]);
  154. useEffect(() => {
  155. const version = ++mountedRequestVersion.current;
  156. const controller = new AbortController();
  157. setMounted((current) => ({ ...current, loading: true, error: null }));
  158. void (async () => {
  159. const ids = new Set<string>();
  160. let currentPage = 1;
  161. let totalPages = 1;
  162. do {
  163. const result = await attachmentApi.mountedByMainPlan(
  164. mainPlan.id,
  165. { page: currentPage, pageSize: 100 },
  166. controller.signal,
  167. );
  168. result.items.forEach((item) => ids.add(item.id));
  169. totalPages = result.totalPages;
  170. currentPage += 1;
  171. } while (currentPage <= totalPages);
  172. if (!controller.signal.aborted && version === mountedRequestVersion.current) {
  173. setMounted({ ids, loading: false, error: null });
  174. }
  175. })().catch((loadError: unknown) => {
  176. if (!controller.signal.aborted && version === mountedRequestVersion.current) {
  177. setMounted({
  178. ids: new Set(),
  179. loading: false,
  180. error: asHttpError(loadError, "已挂载附件加载失败"),
  181. });
  182. }
  183. });
  184. return () => controller.abort();
  185. }, [mainPlan.id, mountedRevision]);
  186. const selectedCount = useMemo(
  187. () => selected.filter((id) => !mounted.ids.has(id)).length,
  188. [mounted.ids, selected],
  189. );
  190. const handleSubmit = async () => {
  191. if (submitting) return;
  192. const attachmentIds = dedupeAttachmentIds(selected);
  193. if (attachmentIds.length === 0) {
  194. setNotice("请至少选择一个未挂载附件");
  195. return;
  196. }
  197. setSubmitting(true);
  198. setError(null);
  199. setNotice("");
  200. try {
  201. const latest = await documentApi.detail(mainPlan.id);
  202. const result = await withDocumentPassword(
  203. mainPlan.id,
  204. mainPlan.documentName,
  205. DocumentPasswordAction.BIND_ATTACHMENT,
  206. (password) => bindingApi.bind(mainPlan.id, {
  207. attachmentIds,
  208. mainPlanRowVersion: latest.rowVersion,
  209. }, undefined, password),
  210. );
  211. setNotice(describeBindResult(result.data));
  212. invalidateDetail("document", mainPlan.id);
  213. notifyContentChanged("documents", "attachments");
  214. setMountedRevision((value) => value + 1);
  215. onChanged();
  216. } catch (submitError) {
  217. const httpError = asHttpError(submitError, "挂载失败");
  218. setError(httpError);
  219. if (httpError.code === "DATA_VERSION_CONFLICT") {
  220. setNotice("主案已被其他用户修改,已刷新最新挂载关系;请核对保留的选择后再次提交。");
  221. invalidateDetail("document", mainPlan.id);
  222. notifyContentChanged("documents", "attachments");
  223. setMountedRevision((value) => value + 1);
  224. onChanged();
  225. }
  226. } finally {
  227. setSubmitting(false);
  228. }
  229. };
  230. const loading =
  231. (preferredAttachmentType ? library.loading : attachments.loading) ||
  232. mounted.loading;
  233. const loadError =
  234. (preferredAttachmentType ? library.error : attachments.error) ??
  235. mounted.error;
  236. return (
  237. <div
  238. className="fixed inset-0 z-[80] flex items-center justify-center p-6"
  239. style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
  240. >
  241. <div
  242. className="w-[980px] max-w-full h-[82vh] flex flex-col shadow-2xl"
  243. style={{ background: WHITE }}
  244. >
  245. <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
  246. <div>
  247. <span className="font-black">挂载共享资料</span>
  248. <span className="ml-3 text-base opacity-75">主案:{mainPlan.documentName}</span>
  249. </div>
  250. <button onClick={onClose} disabled={submitting}><X size={18} /></button>
  251. </div>
  252. <div className="p-4 grid grid-cols-4 gap-2 border-b" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
  253. <label className="col-span-2 flex items-center gap-2 border px-3 h-9">
  254. <Search size={14} />
  255. <input
  256. value={keyword}
  257. onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
  258. placeholder="搜索附件名称、概述或标签"
  259. className="w-full bg-transparent outline-none text-base font-bold"
  260. />
  261. </label>
  262. <select
  263. value={attachmentType}
  264. onChange={(event) => { setAttachmentType(event.target.value as AttachmentTypeCode | ""); setPage(1); }}
  265. className="border px-2 text-base font-bold"
  266. >
  267. <option value="">全部附件类型</option>
  268. {dictionaries.options("attachmentTypes").map((option) => (
  269. <option key={option.code} value={option.code}>{option.label}</option>
  270. ))}
  271. </select>
  272. <input
  273. value={fileExtension}
  274. onChange={(event) => { setFileExtension(event.target.value); setPage(1); }}
  275. placeholder="扩展名,如 docx"
  276. className="border px-3 text-base font-bold outline-none"
  277. />
  278. <label className="text-base font-bold">更新起始
  279. <input type="date" value={updatedFrom} onChange={(event) => { setUpdatedFrom(event.target.value); setPage(1); }} className="block mt-1 border h-8 px-2 w-full" />
  280. </label>
  281. <label className="text-base font-bold">更新截止
  282. <input type="date" value={updatedTo} onChange={(event) => { setUpdatedTo(event.target.value); setPage(1); }} className="block mt-1 border h-8 px-2 w-full" />
  283. </label>
  284. <select value={sortBy} onChange={(event) => setSortBy(event.target.value as "documentName" | "updatedAt")} className="border px-2 text-base font-bold">
  285. <option value="updatedAt">按更新时间</option>
  286. <option value="documentName">按名称</option>
  287. </select>
  288. <select value={sortDirection} onChange={(event) => setSortDirection(event.target.value as "asc" | "desc")} className="border px-2 text-base font-bold">
  289. <option value="desc">降序</option>
  290. <option value="asc">升序</option>
  291. </select>
  292. </div>
  293. <div className="flex-1 overflow-auto">
  294. {loading && <div className="p-10 text-center font-bold" style={{ color: NAVY }}><RefreshCw size={16} className="inline mr-2 animate-spin" />正在加载附件和挂载关系…</div>}
  295. {loadError && !loading && (
  296. <div className="p-10 text-center font-bold" style={{ color: NAVY }}>
  297. <AlertCircle size={16} className="inline mr-2" />
  298. {loadError.httpStatus === 403 ? "当前账号无权管理附件挂载" : loadError.message}
  299. {loadError.requestId && <div className="text-sm mt-2">错误编号:{loadError.requestId}</div>}
  300. </div>
  301. )}
  302. {!loading && !loadError && orderedItems.length === 0 && (
  303. <div className="p-10 text-center text-sm font-bold opacity-55" style={{ color: NAVY }}>暂无符合条件的配套资料</div>
  304. )}
  305. {!loading && !loadError && orderedItems.length > 0 && (
  306. <table className="w-full border-collapse">
  307. <thead className="sticky top-0">
  308. <tr style={{ background: PALE, color: NAVY }}>
  309. {["选择", "附件名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-base font-black">{heading}</th>)}
  310. </tr>
  311. </thead>
  312. <tbody>
  313. {orderedItems.map((item) => {
  314. const alreadyMounted = mounted.ids.has(item.id);
  315. const checked = selected.includes(item.id);
  316. return (
  317. <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 }}>
  318. <td className="px-4 py-3">
  319. <input
  320. type="checkbox"
  321. checked={checked && !alreadyMounted}
  322. disabled={alreadyMounted || submitting}
  323. onChange={() => setSelected((current) => toggleAttachmentSelection(current, item.id))}
  324. />
  325. </td>
  326. <td className="px-4 py-3"><div className="text-base font-black">{item.documentName}</div></td>
  327. <td className="px-4 py-3 text-base font-bold">{dictionaries.label("attachmentTypes", item.attachmentType)}</td>
  328. <td className="px-4 py-3 text-base font-bold">{item.fileExtension}</td>
  329. <td className="px-4 py-3 text-base font-bold">{new Date(item.updatedAt).toLocaleString("zh-CN")}</td>
  330. <td className="px-4 py-3">
  331. {alreadyMounted
  332. ? <span className="inline-flex items-center gap-1 text-base font-black"><Check size={13} />已挂载</span>
  333. : <span className="text-base font-bold opacity-60">可挂载</span>}
  334. </td>
  335. </tr>
  336. );
  337. })}
  338. </tbody>
  339. </table>
  340. )}
  341. </div>
  342. {!preferredAttachmentType && attachments.data && attachments.data.totalPages > 1 && (
  343. <div className="px-4 py-2 flex justify-end gap-4 text-base font-black" style={{ color: NAVY }}>
  344. <button disabled={page <= 1 || submitting} onClick={() => setPage((value) => value - 1)}>上一页</button>
  345. <span>{page}/{attachments.data.totalPages}</span>
  346. <button disabled={page >= attachments.data.totalPages || submitting} onClick={() => setPage((value) => value + 1)}>下一页</button>
  347. </div>
  348. )}
  349. <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
  350. {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>}
  351. {notice && <div className="mb-2 text-base font-black">{notice}</div>}
  352. <div className="flex items-center">
  353. <span className="text-base font-bold">已选择 {selectedCount} 个未挂载附件;翻页和搜索不会清除选择。</span>
  354. <div className="ml-auto flex gap-2">
  355. <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
  356. <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 }}>
  357. {submitting ? "正在挂载…" : "确认挂载"}
  358. </button>
  359. </div>
  360. </div>
  361. </div>
  362. </div>
  363. </div>
  364. );
  365. }