Files
jiapuapp/components/tree/PersonDocumentDialog.vue
T
2026-09-13 17:45:52 +08:00

1165 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<AppDialog
:visible="documentDialogVisible"
eyebrow="重要证件"
:title="documentDialogTitle"
:message="documentDialogMessage"
:confirm-text="documentDialogMode === 'detail' ? '返回证件列表' : '关闭'"
:show-cancel="documentDialogMode === 'unlock'"
cancel-text="返回证件列表"
:close-on-mask="documentState !== 'loading'"
@confirm="handleDocumentConfirm"
@cancel="showDocumentList"
>
<view class="document-dialog-content">
<AppLoading v-if="documentState === 'loading'" text="正在读取重要证件" />
<text v-else-if="documentState === 'error'" class="document-dialog-error">{{ documentError }}</text>
<template v-else-if="documentDialogMode === 'list'">
<AppButton
v-if="member?.canCreateDocument"
block
label="新建证件档案"
@click="openDocumentCreate"
/>
<BatchManagementBar
v-if="deletableDocuments.length"
resource-name="证件档案"
:active="documentBatch.selectionMode.value"
:selected-count="documentBatch.selectedCount.value"
:all-selected="documentBatch.allSelected.value"
:busy="documentBatch.deleting.value"
@start="documentBatch.enterSelectionMode"
@finish="documentBatch.exitSelectionMode"
@toggle-all="documentBatch.toggleAll"
@delete="documentBatch.requestDelete"
/>
<text v-if="documentBatch.notice.value" class="document-batch-notice" role="status">{{ documentBatch.notice.value }}</text>
<text v-if="documentBatch.error.value" class="document-dialog-error" role="alert">{{ documentBatch.error.value }}</text>
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
<view
v-for="documentSummary in documents"
:key="documentSummary.documentId"
class="document-list-item"
role="button"
:aria-label="documentBatch.selectionMode.value && documentSummary.canDelete ? `${documentBatch.isSelected(documentSummary) ? '取消选择' : '选择'}${documentSummary.documentTitle}` : `查看${documentSummary.documentTitle}`"
@click="handleDocumentSummaryClick(documentSummary)"
>
<BatchSelectionMark
v-if="documentBatch.selectionMode.value && documentSummary.canDelete"
:selected="documentBatch.isSelected(documentSummary)"
:label="`证件档案:${documentSummary.documentTitle}`"
@toggle="documentBatch.toggleSelection(documentSummary)"
/>
<view>
<text>{{ documentSummary.documentTitle }}</text>
<text
>{{ documentSummary.lineagePersonName ? `${documentSummary.lineagePersonName} · ` : "" }}{{ documentTypeLabel(documentSummary.documentType) }}{{
documentSummary.maskedIdentifier
? ` · ${documentSummary.maskedIdentifier}`
: ""
}}</text
>
</view>
<text>{{ documentSummary.contentProtected && !documentSummary.contentUnlocked ? '需要密码' : '查看' }} </text>
</view>
</template>
<template v-else-if="documentDialogMode === 'unlock'">
<input
v-model="documentPassword"
class="document-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
<view class="document-unlock-action" role="button" aria-label="解锁重要证件" @click="unlockDocument">
<text>{{ documentState === 'submitting' ? '正在解锁…' : '解锁并查看' }}</text>
</view>
<button class="document-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码</button>
</template>
<template v-else-if="documentDialogMode === 'detail' && selectedDocument">
<view class="document-detail-row"><text>证件类型</text><text>{{ documentTypeLabel(selectedDocument.documentType) }}</text></view>
<view class="document-detail-row"><text>证件号码</text><text>{{ selectedDocument.maskedIdentifier || '未填写' }}</text></view>
<view class="document-detail-row"><text>说明</text><text>{{ selectedDocument.description || '未填写' }}</text></view>
<text class="document-resource-title">证件文件</text>
<text v-if="!selectedDocument.resources.length" class="document-dialog-empty">暂未上传证件文件</text>
<view
v-for="resource in selectedDocument.resources"
:key="resource.resourceId"
class="document-resource-item"
>
<text>{{ resourceUsageLabel(resource.usageType) }}</text>
<view class="document-resource-actions">
<button @click="openDocumentResource(resource)">查看</button>
<button v-if="selectedDocument.canEdit" @click="replaceDocumentResource(resource)">替换</button>
<button v-if="selectedDocument.canEdit" @click="requestDeleteDocumentResource(resource)">删除</button>
</view>
</view>
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
<view v-if="selectedDocument.canEdit || selectedDocument.canDelete" class="document-manage-actions">
<AppButton v-if="selectedDocument.canEdit" compact label="编辑资料" @click="openDocumentEdit" />
<AppButton v-if="selectedDocument.canEdit" compact type="secondary" label="添加文件" @click="addDocumentResource" />
<AppButton
v-if="selectedDocument.canEdit"
compact
type="secondary"
:label="selectedDocument.contentProtected ? '更换密码' : '设置密码'"
@click="openDocumentPassword"
/>
<AppButton
v-if="selectedDocument.canEdit && selectedDocument.contentProtected"
compact
type="secondary"
label="关闭密码"
@click="documentPasswordDisableVisible = true"
/>
<AppButton v-if="selectedDocument.canDelete" compact type="secondary" label="删除证件档案" @click="requestDeleteDocument" />
</view>
</template>
<template v-else-if="documentDialogMode === 'create' || documentDialogMode === 'edit'">
<picker :range="documentTypeLabels" :value="documentTypeIndex" @change="selectDocumentType">
<view class="document-form-field">{{ documentTypeLabel(documentForm.documentType) }}</view>
</picker>
<input v-model="documentForm.documentTitle" class="document-form-field" maxlength="100" placeholder="请输入证件名称" />
<input v-model="documentForm.maskedIdentifier" class="document-form-field" maxlength="100" placeholder="证件号码(建议只填脱敏号码)" />
<textarea
v-model="documentForm.description"
class="document-form-field document-form-field--textarea"
maxlength="1000"
auto-height
placeholder="补充说明(选填)"
/>
<button v-if="documentDialogMode === 'create'" class="document-upload-button" :disabled="documentState === 'submitting'" @click="uploadDocumentImage">
{{ documentUploadReceipt ? `已选择${documentUploadReceipt.fileName || '证件图片'}` : '添加证件图片选填' }}
</button>
<template v-if="documentDialogMode === 'create'">
<text class="document-form-hint">内容密码选填填写后会先创建档案再立即启用密码保护</text>
<input v-model="documentPassword" class="document-form-field" password maxlength="128" placeholder="请输入8至128位内容密码" />
<input v-model="documentPasswordConfirm" class="document-form-field" password maxlength="128" placeholder="请再次输入内容密码" />
</template>
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
<AppButton block :disabled="documentState === 'submitting'" :label="documentState === 'submitting' ? '正在保存' : '保存证件档案'" @click="saveDocument" />
</template>
<template v-else-if="documentDialogMode === 'password'">
<input v-model="documentPassword" class="document-password-input" password maxlength="128" placeholder="请输入8至128位新密码" />
<input v-model="documentPasswordConfirm" class="document-password-input" password maxlength="128" placeholder="请再次输入新密码" />
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
<AppButton
block
:disabled="documentState === 'submitting'"
:label="documentState === 'submitting' ? '正在保存' : '保存内容密码'"
@click="saveDocumentPassword"
/>
</template>
</view>
</AppDialog>
<AppDialog
:visible="documentBatch.confirmationVisible.value"
eyebrow="批量删除"
title="将选中的证件档案删除?"
message="删除后,档案和已关联的证件文件都无法恢复。"
:confirm-text="documentBatch.deleting.value ? '正在删除' : '确认删除'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="documentBatch.confirmDelete"
@cancel="documentBatch.cancelDelete"
/>
<AppDialog
:visible="documentDeleteVisible"
eyebrow="重要证件"
title="确认删除这份证件档案?"
message="删除后,档案和已关联的证件文件都无法恢复。"
:confirm-text="documentState === 'submitting' ? '正在删除' : '确认删除'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmDeleteDocument"
@cancel="documentDeleteVisible = false"
/>
<AppDialog
:visible="Boolean(documentResourceDeleteTarget)"
eyebrow="证件文件"
title="确认删除这个证件文件?"
message="只删除当前文件,证件档案中的其他资料会保留。"
:confirm-text="documentState === 'submitting' ? '正在删除' : '确认删除'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmDeleteDocumentResource"
@cancel="documentResourceDeleteTarget = null"
/>
<AppDialog
:visible="documentPasswordDisableVisible"
eyebrow="内容密码"
title="确认关闭密码保护?"
message="关闭后,有证件查看权限的成员无需输入密码即可查看内容。"
:confirm-text="documentState === 'submitting' ? '正在关闭' : '确认关闭'"
cancel-text="保留密码"
show-cancel
:close-on-mask="false"
@confirm="disableDocumentPassword"
@cancel="documentPasswordDisableVisible = false"
/>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="PERSON_DOCUMENT"
:resource-id="String(selectedDocument?.documentId || '')"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
PERSON_DOCUMENT_RESOURCE_USAGE,
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS
} from "@/services/api/person-document-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { personDocumentApi } from "@/services/api/person-document-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/media-upload.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
genealogyId: { type: String, required: true },
personId: { type: String, default: "" },
member: { type: Object, default: null },
});
const emit = defineEmits(["busy-change", "transient-change"]);
const member = computed(() => props.member);
const documentDialogVisible = ref(false);
const documentDialogMode = ref("list");
const documentState = ref("idle");
const documents = ref([]);
const documentTypeOptions = ref([]);
const selectedDocument = ref(null);
const documentPassword = ref("");
const documentPasswordConfirm = ref("");
const documentAccessToken = ref("");
const documentError = ref("");
const documentDeleteVisible = ref(false);
const documentResourceDeleteTarget = ref(null);
const documentPasswordDisableVisible = ref(false);
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const documentUploadReceipt = ref(null);
const documentForm = reactive({
documentType: "id_card",
documentTitle: "",
maskedIdentifier: "",
description: "",
});
const documentListRequestController = createRequestController();
const documentTypeRequestController = createRequestController();
const documentDetailRequestController = createRequestController();
const documentDraftUploadRequestController = createRequestController();
const documentSaveRequestController = createRequestController();
const documentUnlockRequestController = createRequestController();
const documentFileOpenRequestController = createRequestController();
const documentResourceMutationRequestController = createRequestController();
const documentPasswordMutationRequestController = createRequestController();
const documentDeletionRequestController = createRequestController();
let documentCreateGuard = createNonIdempotentWriteGuard();
let documentResourceCreateGuard = createNonIdempotentWriteGuard();
let isActive = true;
let workflowVersion = 0;
const documentBatch = useBatchDeletion({
items: documents,
getId: (document) => document?.documentId,
deleteOne: (document) =>
personDocumentApi.deletePersonDocument(props.genealogyId, document.documentId, {
requestController: documentDeletionRequestController,
}),
resourceName: "证件档案",
isActive: () => isActive && documentDialogVisible.value,
});
const deletableDocuments = documentBatch.deletableItems;
const documentDialogTitle = computed(() => {
switch (documentDialogMode.value) {
case "unlock":
return "输入内容密码";
case "password":
return selectedDocument.value?.contentProtected ? "更换内容密码" : "设置内容密码";
case "create":
return "新建证件档案";
case "edit":
return "编辑证件档案";
case "detail":
return selectedDocument.value?.documentTitle || "证件详情";
default:
return props.personId
? `${member.value?.name || "成员"}的重要证件`
: "家谱重要证件";
}
});
const documentDialogMessage = computed(() => {
switch (documentDialogMode.value) {
case "unlock":
return "这份证件已设置保护密码,验证后才能查看内容和文件。";
case "create":
return "证件号码建议只填写脱敏信息,避免展示完整敏感号码。";
case "list":
return "证件内容仅向有权限的家谱成员展示。";
default:
return "请妥善保管证件信息,不要随意转发。";
}
});
const documentTypeLabel = (documentType) =>
documentTypeOptions.value.find((option) => option.value === documentType)?.label ||
documents.value.find((document) => document.documentType === documentType)?.documentTypeLabel ||
"重要证件";
const documentTypeLabels = computed(() =>
documentTypeOptions.value.map((documentType) => documentType.label),
);
const documentTypeIndex = computed(() =>
Math.max(
0,
documentTypeOptions.value.findIndex(
(documentType) => documentType.value === documentForm.documentType,
),
),
);
const resourceUsageLabel = (usageType) =>
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS[usageType] || "证件文件";
const hasTransient = computed(() =>
Boolean(
documentDialogVisible.value ||
documentDeleteVisible.value ||
documentResourceDeleteTarget.value ||
documentPasswordDisableVisible.value ||
passwordRecoveryVisible.value,
),
);
const isBusy = computed(() =>
documentState.value === "loading" || documentState.value === "submitting" || passwordRecoveryBusy.value,
);
const shouldIgnoreDocumentFailure = (error) => !isActive || isRequestCancelled(error);
const isCurrentPersonContext = (personId, activeWorkflow) =>
isActive &&
workflowVersion === activeWorkflow &&
props.personId === personId;
const isCurrentDocumentContext = (personId, documentId, activeWorkflow) =>
isCurrentPersonContext(personId, activeWorkflow) &&
String(selectedDocument.value?.documentId || "") === String(documentId);
watch(hasTransient, (value) => emit("transient-change", value), { immediate: true });
watch(isBusy, (value) => emit("busy-change", value), { immediate: true });
const resetDocumentWorkflow = () => {
workflowVersion += 1;
documentListRequestController.abort();
documentTypeRequestController.abort();
documentDetailRequestController.abort();
documentDraftUploadRequestController.abort();
documentSaveRequestController.abort();
documentUnlockRequestController.abort();
documentFileOpenRequestController.abort();
documentResourceMutationRequestController.abort();
documentPasswordMutationRequestController.abort();
documentDeletionRequestController.abort();
documentCreateGuard = createNonIdempotentWriteGuard();
documentResourceCreateGuard = createNonIdempotentWriteGuard();
documentDialogVisible.value = false;
documentDeleteVisible.value = false;
documentResourceDeleteTarget.value = null;
documentPasswordDisableVisible.value = false;
passwordRecoveryVisible.value = false;
documentDialogMode.value = "list";
documentState.value = "idle";
documents.value = [];
selectedDocument.value = null;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentAccessToken.value = "";
documentError.value = "";
documentUploadReceipt.value = null;
documentBatch.exitSelectionMode();
};
const open = async () => {
const activeWorkflow = workflowVersion;
const documentPersonId = props.personId;
documentDialogVisible.value = true;
documentDialogMode.value = "list";
documentState.value = "loading";
documentError.value = "";
selectedDocument.value = null;
documentAccessToken.value = "";
try {
const documentQuery = props.personId
? { lineagePersonId: props.personId }
: {};
const [documentRows, typeOptions] = await Promise.all([
personDocumentApi.getPersonDocuments(
props.genealogyId,
documentQuery,
{ requestController: documentListRequestController },
),
businessDictionaryApi.getBusinessDictionaryOptions("gen_person_document_type", {
requestController: documentTypeRequestController,
}),
]);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
documents.value = documentRows;
documentBatch.exitSelectionMode();
documentTypeOptions.value = typeOptions;
documentState.value = "ready";
} catch (error) {
if (
!isCurrentPersonContext(documentPersonId, activeWorkflow) ||
shouldIgnoreDocumentFailure(error)
) return;
documentState.value = "error";
documentError.value = getRequestErrorMessage(error, "重要证件暂时无法读取,请稍后再试。");
}
};
const showDocumentList = () => {
documentDialogMode.value = "list";
documentState.value = "ready";
selectedDocument.value = null;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentAccessToken.value = "";
documentError.value = "";
};
const openDocumentCreate = () => {
if (!member.value?.canCreateDocument || !documentTypeOptions.value.length) return;
Object.assign(documentForm, {
documentType: documentTypeOptions.value.find((option) => option.default)?.value || documentTypeOptions.value[0].value,
documentTitle: "",
maskedIdentifier: "",
description: "",
});
documentUploadReceipt.value = null;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentDialogMode.value = "create";
documentState.value = "ready";
documentError.value = "";
};
const openDocumentEdit = () => {
if (!selectedDocument.value?.canEdit) return;
const currentTypeAvailable = documentTypeOptions.value.some(
(option) => option.value === selectedDocument.value.documentType,
);
Object.assign(documentForm, {
documentType: currentTypeAvailable ? selectedDocument.value.documentType : "",
documentTitle: selectedDocument.value.documentTitle,
maskedIdentifier: selectedDocument.value.maskedIdentifier,
description: selectedDocument.value.description,
});
documentDialogMode.value = "edit";
documentState.value = "ready";
documentError.value = currentTypeAvailable ? "" : "原证件类型已停用,请重新选择。";
};
const selectDocumentType = (event) => {
documentForm.documentType = documentTypeOptions.value[Number(event.detail.value)]?.value || "";
documentError.value = "";
};
const uploadDocumentImage = async () => {
if (documentState.value === "submitting") return;
const activeWorkflow = workflowVersion;
const documentPersonId = props.personId;
documentState.value = "submitting";
documentError.value = "";
try {
const uploadReceipt = await pickAndUploadImage({
requestController: documentDraftUploadRequestController,
});
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
documentUploadReceipt.value = uploadReceipt;
} catch (error) {
if (
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
!isImagePickCancelled(error) &&
!shouldIgnoreDocumentFailure(error)
) {
documentError.value = getRequestErrorMessage(error, "证件图片上传失败,请稍后重试。");
}
} finally {
if (
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
documentState.value === "submitting"
) {
documentState.value = "ready";
}
}
};
const createDocument = async () => {
if (!member.value?.canCreateDocument || documentState.value === "submitting") return;
if (!documentForm.documentTitle.trim()) {
documentError.value = "请填写证件名称。";
return;
}
if (documentPassword.value || documentPasswordConfirm.value) {
if (documentPassword.value.length < 8 || documentPassword.value.length > 128) {
documentError.value = "内容密码必须为8至128位。";
return;
}
if (documentPassword.value !== documentPasswordConfirm.value) {
documentError.value = "两次输入的内容密码不一致。";
return;
}
}
const documentPersonId = props.personId;
const createPayload = { lineagePersonId: documentPersonId, ...documentForm };
const createAttempt = documentCreateGuard.begin(createPayload);
if (createAttempt === null) {
documentError.value =
"上次创建结果暂时无法确认,请关闭后重新打开证件列表检查,避免重复创建。";
return;
}
documentState.value = "submitting";
documentError.value = "";
const activeWorkflow = workflowVersion;
const uploadReceipt = documentUploadReceipt.value;
let createdDocumentId = "";
let postCreateStage = "creating";
try {
const createdDocument = await personDocumentApi.createPersonDocument(
props.genealogyId,
createPayload,
{ requestController: documentSaveRequestController },
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
createdDocumentId = createdDocument.documentId;
postCreateStage = "linking-file";
if (uploadReceipt?.ossId) {
await personDocumentApi.addPersonDocumentResource(
props.genealogyId,
createdDocument.documentId,
{
ossId: uploadReceipt.ossId,
usageType: PERSON_DOCUMENT_RESOURCE_USAGE.FRONT,
},
{ requestController: documentSaveRequestController },
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
}
postCreateStage = "setting-password";
if (documentPassword.value) {
await personDocumentApi.setPersonDocumentPassword(
props.genealogyId,
createdDocument.documentId,
documentPassword.value,
{ requestController: documentSaveRequestController },
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
}
postCreateStage = "complete";
documentPassword.value = "";
documentPasswordConfirm.value = "";
await open();
} catch (error) {
if (
createdDocumentId &&
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
!isRequestCancelled(error)
) {
documentPassword.value = "";
documentPasswordConfirm.value = "";
await loadDocumentDetail(createdDocumentId, "");
if (
isCurrentDocumentContext(
documentPersonId,
createdDocumentId,
activeWorkflow,
)
) {
documentError.value = postCreateStage === "setting-password"
? "证件档案已创建,图片也已处理,但内容密码设置结果暂时无法确认。请在详情中重新设置,当前档案可能尚未受到密码保护。"
: "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
} else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
documentState.value = "error";
documentError.value = postCreateStage === "setting-password"
? "证件档案已创建,但内容密码设置结果暂时无法确认。请重新打开详情检查,避免重复创建档案。"
: "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
}
return;
}
if (
!createdDocumentId &&
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
documentCreateGuard.recordFailure(createAttempt, error)
) {
documentState.value = "ready";
documentError.value =
"证件档案创建结果暂时无法确认,请关闭后重新打开证件列表检查,避免重复创建。";
return;
}
if (
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
!shouldIgnoreDocumentFailure(error)
) {
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "证件档案保存失败,请稍后重试。");
}
}
};
const loadDocumentDetail = async (documentId, accessToken) => {
const activeWorkflow = workflowVersion;
const documentPersonId = props.personId;
documentDialogMode.value = "detail";
documentState.value = "loading";
try {
const documentDetail = await personDocumentApi.getPersonDocument(
props.genealogyId,
documentId,
accessToken,
{ requestController: documentDetailRequestController },
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
selectedDocument.value = documentDetail;
documentAccessToken.value = accessToken;
documentState.value = "ready";
} catch (error) {
if (
!isCurrentPersonContext(documentPersonId, activeWorkflow) ||
shouldIgnoreDocumentFailure(error)
) return;
documentState.value = "error";
documentError.value = getRequestErrorMessage(error, "证件详情暂时无法读取,请稍后再试。");
}
};
const updateDocument = async () => {
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
if (!documentForm.documentTitle.trim()) {
documentError.value = "请填写证件名称。";
return;
}
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
const targetPersonId = props.personId || selectedDocument.value.lineagePersonId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
try {
const updatedDocument = await personDocumentApi.updatePersonDocument(
props.genealogyId,
documentId,
{ lineagePersonId: targetPersonId, ...documentForm },
{ requestController: documentSaveRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
selectedDocument.value = updatedDocument;
await loadDocumentDetail(updatedDocument.documentId, documentAccessToken.value);
} catch (error) {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!shouldIgnoreDocumentFailure(error)
) {
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "证件资料保存失败,请稍后重试。");
}
}
};
const saveDocument = () =>
documentDialogMode.value === "edit" ? updateDocument() : createDocument();
const openDocument = async (documentSummary) => {
selectedDocument.value = documentSummary;
documentError.value = "";
if (documentSummary.contentProtected && !documentSummary.contentUnlocked) {
documentDialogMode.value = "unlock";
documentState.value = "ready";
documentPassword.value = "";
return;
}
await loadDocumentDetail(documentSummary.documentId, "");
};
const handleDocumentSummaryClick = (documentSummary) => {
if (documentBatch.selectionMode.value && documentSummary?.canDelete) {
documentBatch.toggleSelection(documentSummary);
return;
}
openDocument(documentSummary);
};
const unlockDocument = async () => {
if (documentState.value === "submitting" || !selectedDocument.value) return;
documentError.value = "";
if (documentPassword.value.length < 8 || documentPassword.value.length > 128) {
documentError.value = "请输入8至128位内容密码。";
return;
}
documentState.value = "submitting";
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
try {
const accessGrant = await personDocumentApi.unlockPersonDocument(
props.genealogyId,
documentId,
documentPassword.value,
{ requestController: documentUnlockRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
await loadDocumentDetail(documentId, accessGrant.accessToken);
documentPassword.value = "";
} catch (error) {
if (
!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) ||
shouldIgnoreDocumentFailure(error)
) return;
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
}
};
const completePasswordRecovery = (newPassword) => {
documentPassword.value = newPassword;
documentError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openDocumentResource = async (resource) => {
if (!selectedDocument.value) return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
documentFileOpenRequestController.abort();
documentError.value = "";
try {
const fileAccess = await personDocumentApi.getPersonDocumentResourceAccess(
props.genealogyId,
documentId,
resource.resourceId,
documentAccessToken.value,
{ requestController: documentFileOpenRequestController },
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
if (String(fileAccess.mediaType || "").toLowerCase().startsWith("image/")) {
uni.previewImage({ urls: [fileAccess.accessUrl], current: fileAccess.accessUrl });
return;
}
let downloadTask = null;
const abortDownload = () => downloadTask?.abort?.();
const finishDownload = () => documentFileOpenRequestController.release(abortDownload);
downloadTask = uni.downloadFile({
url: fileAccess.accessUrl,
success: ({ statusCode, tempFilePath }) => {
finishDownload();
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
if (statusCode === 200 && tempFilePath) {
uni.openDocument({ filePath: tempFilePath, showMenu: true });
return;
}
documentError.value = "证件文件下载失败,请稍后再试。";
},
fail: () => {
finishDownload();
if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
documentError.value = "证件文件下载失败,请稍后再试。";
}
},
});
documentFileOpenRequestController.bind(abortDownload);
} catch (error) {
if (
!isCurrentPersonContext(documentPersonId, activeWorkflow) ||
shouldIgnoreDocumentFailure(error)
) return;
documentError.value = getRequestErrorMessage(error, "证件文件暂时无法打开,请稍后再试。");
}
};
const addDocumentResource = async () => {
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
const resourceAttempt = documentResourceCreateGuard.begin({
genealogyId: props.genealogyId,
documentId,
});
if (resourceAttempt === null) {
documentError.value =
"上次文件添加结果暂时无法确认,请关闭后重新打开证件详情检查,避免重复添加。";
return;
}
documentState.value = "submitting";
documentError.value = "";
let resourceWriteStarted = false;
try {
const uploadReceipt = await pickAndUploadImage({
requestController: documentResourceMutationRequestController,
});
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
resourceWriteStarted = true;
await personDocumentApi.addPersonDocumentResource(
props.genealogyId,
documentId,
{
ossId: uploadReceipt.ossId,
usageType: PERSON_DOCUMENT_RESOURCE_USAGE.ATTACHMENT,
},
{ requestController: documentResourceMutationRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
await loadDocumentDetail(documentId, documentAccessToken.value);
} catch (error) {
if (
resourceWriteStarted &&
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
documentResourceCreateGuard.recordFailure(resourceAttempt, error)
) {
documentError.value =
"证件文件添加结果暂时无法确认,请关闭后重新打开证件详情检查,避免重复添加。";
return;
}
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!isImagePickCancelled(error) &&
!shouldIgnoreDocumentFailure(error)
) {
documentError.value = getRequestErrorMessage(error, "证件文件添加失败,请稍后重试。");
}
} finally {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
documentState.value === "submitting"
) {
documentState.value = "ready";
}
}
};
const replaceDocumentResource = async (resource) => {
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
try {
const replacementUploadReceipt = await pickAndUploadImage({
requestController: documentResourceMutationRequestController,
});
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
await personDocumentApi.updatePersonDocumentResource(
props.genealogyId,
documentId,
resource.resourceId,
{
ossId: replacementUploadReceipt.ossId,
usageType: resource.usageType,
sortOrder: resource.sortOrder,
},
{ requestController: documentResourceMutationRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
await loadDocumentDetail(documentId, documentAccessToken.value);
} catch (error) {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!isImagePickCancelled(error) &&
!shouldIgnoreDocumentFailure(error)
) {
documentError.value = getRequestErrorMessage(error, "证件文件替换失败,请稍后重试。");
}
} finally {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
documentState.value === "submitting"
) {
documentState.value = "ready";
}
}
};
const requestDeleteDocumentResource = (resource) => {
if (!selectedDocument.value?.canEdit) return;
documentResourceDeleteTarget.value = resource;
};
const confirmDeleteDocumentResource = async () => {
if (!selectedDocument.value?.canEdit || !documentResourceDeleteTarget.value || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const resourceId = documentResourceDeleteTarget.value.resourceId;
const activeWorkflow = workflowVersion;
documentState.value = "submitting";
try {
await personDocumentApi.deletePersonDocumentResource(
props.genealogyId,
documentId,
resourceId,
{ requestController: documentResourceMutationRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
documentResourceDeleteTarget.value = null;
await loadDocumentDetail(documentId, documentAccessToken.value);
} catch (error) {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!shouldIgnoreDocumentFailure(error)
) {
documentResourceDeleteTarget.value = null;
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "证件文件删除失败,请稍后重试。");
}
}
};
const openDocumentPassword = () => {
if (!selectedDocument.value?.canEdit) return;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentError.value = "";
documentDialogMode.value = "password";
};
const saveDocumentPassword = async () => {
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
if (documentPassword.value.length < 8 || documentPassword.value.length > 128) {
documentError.value = "请输入8至128位内容密码。";
return;
}
if (documentPassword.value !== documentPasswordConfirm.value) {
documentError.value = "两次输入的密码不一致,请重新输入。";
return;
}
documentState.value = "submitting";
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
try {
await personDocumentApi.setPersonDocumentPassword(
props.genealogyId,
documentId,
documentPassword.value,
{ requestController: documentPasswordMutationRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
documentPassword.value = "";
documentPasswordConfirm.value = "";
await open();
} catch (error) {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!shouldIgnoreDocumentFailure(error)
) {
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "内容密码保存失败,请稍后重试。");
}
}
};
const disableDocumentPassword = async () => {
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
documentState.value = "submitting";
try {
await personDocumentApi.disablePersonDocumentPassword(
props.genealogyId,
documentId,
{ requestController: documentPasswordMutationRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
documentPasswordDisableVisible.value = false;
await loadDocumentDetail(documentId, "");
} catch (error) {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!shouldIgnoreDocumentFailure(error)
) {
documentPasswordDisableVisible.value = false;
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "密码保护暂时无法关闭,请稍后重试。");
}
}
};
const handleDocumentConfirm = () => {
if (isBusy.value) return;
if (documentDialogMode.value === "detail" || documentDialogMode.value === "create") {
showDocumentList();
return;
}
resetDocumentWorkflow();
};
const requestDeleteDocument = () => {
if (!selectedDocument.value?.canDelete) return;
documentDeleteVisible.value = true;
};
const confirmDeleteDocument = async () => {
if (!selectedDocument.value?.canDelete || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
documentState.value = "submitting";
try {
await personDocumentApi.deletePersonDocument(
props.genealogyId,
documentId,
{ requestController: documentDeletionRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
documentDeleteVisible.value = false;
await open();
} catch (error) {
if (
isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow) &&
!shouldIgnoreDocumentFailure(error)
) {
documentDeleteVisible.value = false;
documentState.value = "ready";
documentError.value = getRequestErrorMessage(error, "证件档案删除失败,请稍后重试。");
}
}
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (documentResourceDeleteTarget.value) {
documentResourceDeleteTarget.value = null;
return true;
}
if (documentDeleteVisible.value) {
documentDeleteVisible.value = false;
return true;
}
if (documentPasswordDisableVisible.value) {
documentPasswordDisableVisible.value = false;
return true;
}
if (documentDialogVisible.value) {
resetDocumentWorkflow();
return true;
}
return false;
};
defineExpose({ open, closeTransient, reset: resetDocumentWorkflow });
onUnmounted(() => {
isActive = false;
resetDocumentWorkflow();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.document-dialog-content {
width: 100%;
margin-top: 18rpx;
}
.document-list-item,
.document-resource-item,
.document-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 10rpx;
padding: 18rpx;
border: 1rpx solid rgba(159, 35, 35, 0.16);
background: rgba(255, 252, 242, 0.9);
text-align: left;
}
.document-list-item > view { min-width: 0; }
.document-list-item text,
.document-resource-item text,
.document-detail-row text,
.document-dialog-empty,
.document-dialog-error,
.document-resource-title {
display: block;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.document-form-field {
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
margin-top: 14rpx;
padding: 16rpx 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.3);
border-radius: 8rpx;
background: rgba(255, 252, 245, 0.82);
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.document-form-field--textarea { min-height: 132rpx; }
.document-form-hint { display: block; margin-top: 16rpx; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); line-height: 1.5; }
.document-upload-button {
width: 100%;
min-height: 76rpx;
margin: 14rpx 0;
border: 1rpx solid rgba(159, 23, 15, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.document-upload-button::after { border: 0; }
.document-recovery-link { display: block; min-height: var(--app-touch-min); margin: 6rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.document-recovery-link::after { border: 0; }
.document-manage-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
margin-top: 18rpx;
gap: 10rpx;
}
.document-manage-actions .app-button { width: auto; min-width: 150rpx; }
.document-resource-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8rpx; }
.document-resource-actions button {
min-height: 64rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba(159, 35, 35, 0.22);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
}
.document-resource-actions button::after { border: 0; }
.document-list-item > view text:first-child,
.document-resource-item text:first-child,
.document-detail-row text:last-child { color: $ink; }
.document-list-item > text,
.document-resource-item text:last-child { flex: 0 0 auto; color: $brand-red; }
.document-password-input {
box-sizing: border-box;
width: 100%;
min-height: 78rpx;
padding: 0 20rpx;
border: 1rpx solid rgba(159, 35, 35, 0.3);
background: #fffdf7;
color: $ink;
text-align: left;
}
.document-password-input + .document-password-input { margin-top: 14rpx; }
.document-unlock-action {
@include adaptive-scroll-button(primary);
display: flex;
min-height: 74rpx;
align-items: center;
justify-content: center;
margin-top: 18rpx;
}
.document-unlock-action text { color: #fff9ed; font-weight: 700; }
.document-resource-title {
margin-top: 22rpx;
color: $brand-red;
font-weight: 700;
text-align: left;
}
.document-dialog-empty,
.document-dialog-error { padding: 24rpx 10rpx; text-align: center; }
.document-dialog-error { color: $brand-red; }
.document-batch-notice { display: block; padding: 14rpx 10rpx; color: #426538; font-size: clamp(13px, 21rpx, 16px); line-height: 1.55; }
</style>