|
|
@@ -1,11 +1,14 @@
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
-import { Check, FilePlus2, Link2, Lock, RefreshCw, Trash2, Upload, X } from "lucide-react";
|
|
|
+import { Check, FilePlus2, FileSearch, Link2, Lock, RefreshCw, Trash2, Upload, X } from "lucide-react";
|
|
|
import { HttpError } from "../../shared/api/errors";
|
|
|
import {
|
|
|
AttachmentType,
|
|
|
+ ContentExtractStatus,
|
|
|
DocumentPasswordAction,
|
|
|
DocumentType,
|
|
|
SecurityLevel,
|
|
|
+ type AttachmentType as AttachmentTypeCode,
|
|
|
+ type ContentExtractStatus as ContentExtractStatusCode,
|
|
|
type DocumentPasswordActionCode,
|
|
|
} from "../../shared/api/enums";
|
|
|
import {
|
|
|
@@ -15,7 +18,11 @@ import {
|
|
|
import { notifyContentChanged } from "../../shared/state/contentRefresh";
|
|
|
import { attachmentApi } from "../attachments/attachmentApi";
|
|
|
import { useCategoryTree } from "../categories/categoryState";
|
|
|
+import { tipProps } from "../../shared/ui/hoverTip";
|
|
|
import { flattenCategoryOptions } from "../categories/categoryTree";
|
|
|
+import { flattenOrganizations } from "../directory/directoryState";
|
|
|
+import { organizationApi } from "../directory/organizationApi";
|
|
|
+import type { OrganizationNode } from "../directory/directoryTypes";
|
|
|
import { useDictionaries } from "../dictionaries/dictionaryContext";
|
|
|
import { documentApi } from "../documents/documentApi";
|
|
|
import {
|
|
|
@@ -74,6 +81,14 @@ const PASSWORD_ACTION_LABELS: Record<DocumentPasswordActionCode, string> = {
|
|
|
[DocumentPasswordAction.UNBIND_ATTACHMENT]: "解除挂载",
|
|
|
};
|
|
|
|
|
|
+/** 正文提取状态标签(编辑弹窗管理员排障区块) */
|
|
|
+const EXTRACT_STATUS_LABELS: Record<ContentExtractStatusCode, string> = {
|
|
|
+ [ContentExtractStatus.SUCCESS]: "解析成功",
|
|
|
+ [ContentExtractStatus.EMPTY]: "未提取到正文文本(可能是扫描件或无文本层)",
|
|
|
+ [ContentExtractStatus.UNSUPPORTED]: "该文件类型不支持正文解析",
|
|
|
+ [ContentExtractStatus.FAILED]: "正文解析失败",
|
|
|
+};
|
|
|
+
|
|
|
function mutationMessage(error: HttpError): string {
|
|
|
const messages: Record<string, string> = {
|
|
|
PAYLOAD_TOO_LARGE: "文件或本次请求过大,请减少文件数量或选择更小的文件",
|
|
|
@@ -185,12 +200,16 @@ function defaultDocumentMetadata(
|
|
|
};
|
|
|
}
|
|
|
|
|
|
-export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, mainOnly = false, onClose }: {
|
|
|
+export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, initialAttachmentType, mainOnly = false, onUploaded, onClose }: {
|
|
|
kind: "document" | "attachment";
|
|
|
documentType?: "MAIN" | "SUB_PLAN";
|
|
|
initialParentId?: string;
|
|
|
+ /** 附件类型预置值(管理页 Tab 页"上传"入口按当前页签类型上传共享资料)。 */
|
|
|
+ initialAttachmentType?: AttachmentTypeCode;
|
|
|
/** 仅允许上传主案:隐藏方案类型切换,documentType 锁定为 MAIN。 */
|
|
|
mainOnly?: boolean;
|
|
|
+ /** 上传成功回调(返回创建的文档详情;管理页附件 Tab 上传后据此自动挂载到当前主案)。 */
|
|
|
+ onUploaded?: (detail: DocumentDetail) => void;
|
|
|
onClose: () => void;
|
|
|
}) {
|
|
|
const references = useReferenceData();
|
|
|
@@ -203,7 +222,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
|
|
|
const [parentDocumentId, setParentDocumentId] = useState(initialParentId ?? "");
|
|
|
const [categoryId, setCategoryId] = useState("");
|
|
|
const [securityLevel, setSecurityLevel] = useState(SecurityLevel.INTERNAL);
|
|
|
- const [attachmentType, setAttachmentType] = useState(AttachmentType.SUPPORT_ACHIEVEMENT);
|
|
|
+ const [attachmentType, setAttachmentType] = useState<AttachmentTypeCode>(initialAttachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
const [error, setError] = useState<HttpError | null>(null);
|
|
|
const [formError, setFormError] = useState("");
|
|
|
@@ -241,6 +260,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
|
|
|
setSubmitting(true);
|
|
|
controller.current = new AbortController();
|
|
|
try {
|
|
|
+ let created: DocumentDetail;
|
|
|
if (kind === "attachment") {
|
|
|
const metadata: AttachmentCreateMetadata = {
|
|
|
documentName: documentName.trim(),
|
|
|
@@ -251,6 +271,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
|
|
|
const detail = await attachmentMutationApi.create(file!, metadata, controller.current.signal);
|
|
|
cacheDetail("attachment", detail);
|
|
|
notifyContentChanged("attachments");
|
|
|
+ created = detail;
|
|
|
} else {
|
|
|
const base = {
|
|
|
documentName: documentName.trim(),
|
|
|
@@ -271,7 +292,9 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
|
|
|
const detail = await documentMutationApi.create(file!, metadata, controller.current.signal);
|
|
|
cacheDetail("document", detail);
|
|
|
notifyContentChanged("documents");
|
|
|
+ created = detail;
|
|
|
}
|
|
|
+ onUploaded?.(created);
|
|
|
onClose();
|
|
|
} catch (value) {
|
|
|
const nextError = asMutationError(value);
|
|
|
@@ -461,7 +484,7 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
|
|
|
const attachmentMetadata = kind === "attachment" ? metadata as AttachmentCreateMetadata : null;
|
|
|
return (
|
|
|
<div key={draft.key} className="p-3 border grid grid-cols-12 gap-2 items-end" style={{ borderColor: "rgba(26,82,153,.18)" }}>
|
|
|
- <div className="col-span-2 text-xs font-black truncate" title={draft.file.name}>{index + 1}. {draft.file.name}</div>
|
|
|
+ <div className="col-span-2 text-xs font-black truncate" {...tipProps(draft.file.name)}>{index + 1}. {draft.file.name}</div>
|
|
|
<div className="col-span-2"><input value={metadata.documentName} onChange={(event) => updateMetadata(draft.key, { documentName: event.target.value })} className={inputClass} style={selectStyle} /></div>
|
|
|
{kind === "document" ? (
|
|
|
<>
|
|
|
@@ -498,12 +521,14 @@ interface DocumentRelationRow {
|
|
|
relationType: string;
|
|
|
}
|
|
|
|
|
|
-export function EditMetadataModal({ item, canManagePassword = false, canManageRelations = false, onClose }: {
|
|
|
+export function EditMetadataModal({ item, canManagePassword = false, canManageRelations = false, canManageOrganization = false, onClose }: {
|
|
|
item: DocumentSummary;
|
|
|
/** 管理员可在编辑弹窗维护主案密码锁 */
|
|
|
canManagePassword?: boolean;
|
|
|
/** 管理员可维护方案的关联关系(非管理员仅只读展示) */
|
|
|
canManageRelations?: boolean;
|
|
|
+ /** 管理员可在编辑弹窗调整所属单位;非管理员只读展示当前归属 */
|
|
|
+ canManageOrganization?: boolean;
|
|
|
onClose: () => void;
|
|
|
}) {
|
|
|
const attachment = item.documentType === DocumentType.ATTACHMENT;
|
|
|
@@ -517,6 +542,9 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
const [securityLevel, setSecurityLevel] = useState(item.securityLevel);
|
|
|
const [attachmentType, setAttachmentType] = useState(item.attachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
|
|
|
const [tags, setTags] = useState(item.tags.join(","));
|
|
|
+ // 所属单位(归属部门):仅管理员可调整到其他部门
|
|
|
+ const [organizationId, setOrganizationId] = useState(item.organizationId ?? "");
|
|
|
+ const [organizations, setOrganizations] = useState<OrganizationNode[]>([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
const [error, setError] = useState<HttpError | null>(null);
|
|
|
@@ -534,6 +562,9 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
const [relationSubmitting, setRelationSubmitting] = useState(false);
|
|
|
const [relationError, setRelationError] = useState<HttpError | null>(null);
|
|
|
const [relationFormError, setRelationFormError] = useState("");
|
|
|
+ // 正文解析状态(仅管理员可见该区块;字段由详情接口按角色下发)
|
|
|
+ const [reExtracting, setReExtracting] = useState(false);
|
|
|
+ const [reExtractError, setReExtractError] = useState<HttpError | null>(null);
|
|
|
|
|
|
const loadLatest = async (preserveError = false) => {
|
|
|
setLoading(true);
|
|
|
@@ -549,6 +580,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
setSecurityLevel(latest.securityLevel);
|
|
|
setAttachmentType(latest.attachmentType ?? AttachmentType.SUPPORT_ACHIEVEMENT);
|
|
|
setTags(latest.tags.join(","));
|
|
|
+ setOrganizationId(latest.organizationId ?? "");
|
|
|
setPwActions(latest.passwordProtectedActions ?? []);
|
|
|
cacheDetail(attachment ? "attachment" : "document", latest);
|
|
|
invalidatePreview(item.id);
|
|
|
@@ -592,11 +624,35 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
|
|
|
useEffect(() => { void loadRelations(); }, [item.id]);
|
|
|
|
|
|
+ /** 管理员加载启用部门树作为所属单位选项;接口仅管理员可用,失败时下拉退化为仅显示当前归属 */
|
|
|
+ useEffect(() => {
|
|
|
+ if (!canManageOrganization) return;
|
|
|
+ const controller = new AbortController();
|
|
|
+ organizationApi.getTree({ status: "ENABLED" }, controller.signal)
|
|
|
+ .then((tree) => setOrganizations(tree))
|
|
|
+ .catch(() => undefined);
|
|
|
+ return () => controller.abort();
|
|
|
+ }, [canManageOrganization]);
|
|
|
+
|
|
|
+ /** 所属单位下拉选项:启用部门按路径展示;当前归属不在启用列表时补兜底项,保证原值可回显 */
|
|
|
+ const organizationOptions = useMemo(() => {
|
|
|
+ const options = flattenOrganizations(organizations).map((org) => ({
|
|
|
+ id: org.id,
|
|
|
+ label: org.orgPath.replace(/^\//, "").replaceAll("/", " / ") || org.orgName,
|
|
|
+ }));
|
|
|
+ if (organizationId && !options.some((option) => option.id === organizationId)) {
|
|
|
+ options.push({ id: organizationId, label: detail?.organizationName ?? organizationId });
|
|
|
+ }
|
|
|
+ return options;
|
|
|
+ }, [organizations, organizationId, detail?.organizationName]);
|
|
|
+
|
|
|
const submit = async () => {
|
|
|
if (!detail || submitting) return;
|
|
|
setSubmitting(true);
|
|
|
setError(null);
|
|
|
try {
|
|
|
+ // 所属单位为空(历史数据未设置归属)时不提交该键,由服务端沿用现有归属
|
|
|
+ const organizationPatch = organizationId ? { organizationId } : {};
|
|
|
const latest = attachment
|
|
|
? await attachmentMutationApi.update(item.id, {
|
|
|
documentName: documentName.trim(),
|
|
|
@@ -604,6 +660,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
summary: summary.trim() || null,
|
|
|
tags: parseTags(tags),
|
|
|
rowVersion: detail.rowVersion,
|
|
|
+ ...organizationPatch,
|
|
|
})
|
|
|
: mainPlan
|
|
|
? await withDocumentPassword(item.id, item.documentName, DocumentPasswordAction.EDIT,
|
|
|
@@ -614,6 +671,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
securityLevel,
|
|
|
tags: parseTags(tags),
|
|
|
rowVersion: detail.rowVersion,
|
|
|
+ ...organizationPatch,
|
|
|
}, undefined, password))
|
|
|
: await documentMutationApi.update(item.id, {
|
|
|
documentName: documentName.trim(),
|
|
|
@@ -621,6 +679,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
securityLevel,
|
|
|
tags: parseTags(tags),
|
|
|
rowVersion: detail.rowVersion,
|
|
|
+ ...organizationPatch,
|
|
|
});
|
|
|
cacheDetail(attachment ? "attachment" : "document", latest);
|
|
|
invalidatePreview(item.id);
|
|
|
@@ -769,6 +828,24 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
setRelationRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch } : row));
|
|
|
};
|
|
|
|
|
|
+ /** 管理员重新解析正文:成功后回写详情(含 rowVersion),并刷新预览/列表缓存 */
|
|
|
+ const reExtract = async () => {
|
|
|
+ if (!detail || reExtracting) return;
|
|
|
+ setReExtracting(true);
|
|
|
+ setReExtractError(null);
|
|
|
+ try {
|
|
|
+ const latest = await documentMutationApi.reExtract(item.id);
|
|
|
+ setDetail(latest);
|
|
|
+ cacheDetail(attachment ? "attachment" : "document", latest);
|
|
|
+ invalidatePreview(item.id);
|
|
|
+ notifyContentChanged(attachment ? "attachments" : "documents");
|
|
|
+ } catch (value) {
|
|
|
+ setReExtractError(asMutationError(value));
|
|
|
+ } finally {
|
|
|
+ setReExtracting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
return (
|
|
|
<Modal title={`编辑 · ${item.documentName}`} onClose={onClose}>
|
|
|
<div className="p-6 space-y-3">
|
|
|
@@ -881,6 +958,42 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
|
|
|
)
|
|
|
)}
|
|
|
</>}
|
|
|
+ {detail.contentExtractStatus !== undefined && (
|
|
|
+ <div className="border p-3 space-y-2" style={{ borderColor: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "rgba(200,30,30,.35)" : "rgba(26,82,153,.18)", background: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "#FFF5F5" : "#F5FAFF" }} data-testid="content-extract-panel">
|
|
|
+ <div className="text-xs font-black flex items-center gap-1" style={{ color: NAVY }}>
|
|
|
+ <FileSearch size={13} />正文解析:{EXTRACT_STATUS_LABELS[detail.contentExtractStatus]}
|
|
|
+ {detail.contentExtractedAt ? <span className="font-bold opacity-70">({detail.contentExtractedAt.replace("T", " ").replace("Z", " UTC")})</span> : null}
|
|
|
+ </div>
|
|
|
+ {detail.contentExtractStatus === ContentExtractStatus.FAILED && (
|
|
|
+ <div className="text-xs font-bold" style={{ color: "#B91C1C" }} data-testid="content-extract-error">
|
|
|
+ 失败原因:{detail.contentExtractError ?? "未知错误"}。可点击"重新解析"重试,若持续失败请检查文件是否损坏或加密。
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {detail.contentExtractStatus === ContentExtractStatus.EMPTY && (
|
|
|
+ <div className="text-xs font-bold" style={{ color: NAVY }}>未提取到正文文本,全文检索与正文预览不可用;若为扫描件属预期行为。</div>
|
|
|
+ )}
|
|
|
+ <ErrorBlock error={reExtractError} />
|
|
|
+ <div className="flex justify-end">
|
|
|
+ <button type="button" onClick={() => void reExtract()} disabled={reExtracting} className="px-3 py-1.5 text-xs font-black text-white disabled:opacity-50" style={{ background: BLUE }} data-testid="re-extract-button">
|
|
|
+ <RefreshCw size={12} className={reExtracting ? "inline animate-spin mr-1" : "inline mr-1"} />{reExtracting ? "解析中…" : "重新解析"}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <Field label="所属单位">
|
|
|
+ <select
|
|
|
+ value={organizationId}
|
|
|
+ disabled={!canManageOrganization || organizations.length === 0}
|
|
|
+ onChange={(event) => setOrganizationId(event.target.value)}
|
|
|
+ className={inputClass}
|
|
|
+ style={selectStyle}
|
|
|
+ data-testid="organization-select"
|
|
|
+ >
|
|
|
+ {organizationId === "" && <option value="">未设置单位</option>}
|
|
|
+ {organizationOptions.map((option) => <option key={option.id} value={option.id} title={option.label}>{option.label}</option>)}
|
|
|
+ </select>
|
|
|
+ {!canManageOrganization && <span className="block mt-1 text-xs opacity-70" style={{ color: NAVY }}>调整所属单位需管理员权限</span>}
|
|
|
+ </Field>
|
|
|
{/* 概述/标签栏已按要求隐藏:state 保留服务端原值,保存时原样提交,不改动既有数据 */}
|
|
|
</>}
|
|
|
<ErrorBlock error={error} />
|