1031 lines
40 KiB
Vue
1031 lines
40 KiB
Vue
<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"
|
||
/>
|
||
<text v-if="!documents.length" class="document-dialog-empty">这位成员还没有可查看的重要证件</text>
|
||
<view
|
||
v-for="documentSummary in documents"
|
||
:key="documentSummary.documentId"
|
||
class="document-list-item"
|
||
role="button"
|
||
:aria-label="`查看${documentSummary.documentTitle}`"
|
||
@click="openDocument(documentSummary)"
|
||
>
|
||
<view>
|
||
<text>{{ documentSummary.documentTitle }}</text>
|
||
<text
|
||
>{{ 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>
|
||
</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 && member?.canManageDocuments" @click="replaceDocumentResource(resource)">替换</button>
|
||
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="requestDeleteDocumentResource(resource)">删除</button>
|
||
</view>
|
||
</view>
|
||
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
|
||
<view v-if="member?.canManageDocuments" 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>
|
||
<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="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"
|
||
/>
|
||
</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 {
|
||
PERSON_DOCUMENT_RESOURCE_USAGE,
|
||
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS,
|
||
PERSON_DOCUMENT_TYPE_OPTIONS as documentTypeOptions
|
||
} from "@/services/api/person-document-contract.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 { 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, required: true },
|
||
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 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 documentUploadReceipt = ref(null);
|
||
const documentForm = reactive({
|
||
documentType: "id_card",
|
||
documentTitle: "",
|
||
maskedIdentifier: "",
|
||
description: "",
|
||
});
|
||
const documentListRequestController = 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 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 `${member.value?.name || "成员"}的重要证件`;
|
||
}
|
||
});
|
||
const documentDialogMessage = computed(() => {
|
||
switch (documentDialogMode.value) {
|
||
case "unlock":
|
||
return "这份证件已设置保护密码,验证后才能查看内容和文件。";
|
||
case "create":
|
||
return "证件号码建议只填写脱敏信息,避免展示完整敏感号码。";
|
||
case "list":
|
||
return "证件内容仅向有权限的家谱成员展示。";
|
||
default:
|
||
return "请妥善保管证件信息,不要随意转发。";
|
||
}
|
||
});
|
||
const documentTypeLabel = (documentType) =>
|
||
({
|
||
id_card: "身份证",
|
||
household_register: "户口簿",
|
||
birth_certificate: "出生证明",
|
||
marriage_certificate: "结婚证",
|
||
other: "其他证件",
|
||
})[documentType] || "重要证件";
|
||
const documentTypeLabels = computed(() =>
|
||
documentTypeOptions.map((documentType) => documentType.label),
|
||
);
|
||
const documentTypeIndex = computed(() =>
|
||
Math.max(
|
||
0,
|
||
documentTypeOptions.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,
|
||
),
|
||
);
|
||
const isBusy = computed(() =>
|
||
documentState.value === "loading" || documentState.value === "submitting",
|
||
);
|
||
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();
|
||
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;
|
||
documentDialogMode.value = "list";
|
||
documentState.value = "idle";
|
||
documents.value = [];
|
||
selectedDocument.value = null;
|
||
documentPassword.value = "";
|
||
documentPasswordConfirm.value = "";
|
||
documentAccessToken.value = "";
|
||
documentError.value = "";
|
||
documentUploadReceipt.value = null;
|
||
};
|
||
|
||
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 documentRows = await personDocumentApi.getPersonDocuments(
|
||
props.genealogyId,
|
||
{ lineagePersonId: props.personId },
|
||
{ requestController: documentListRequestController },
|
||
);
|
||
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||
documents.value = documentRows;
|
||
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) return;
|
||
Object.assign(documentForm, {
|
||
documentType: "id_card",
|
||
documentTitle: "",
|
||
maskedIdentifier: "",
|
||
description: "",
|
||
});
|
||
documentUploadReceipt.value = null;
|
||
documentDialogMode.value = "create";
|
||
documentState.value = "ready";
|
||
documentError.value = "";
|
||
};
|
||
const openDocumentEdit = () => {
|
||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
|
||
Object.assign(documentForm, {
|
||
documentType: selectedDocument.value.documentType,
|
||
documentTitle: selectedDocument.value.documentTitle,
|
||
maskedIdentifier: selectedDocument.value.maskedIdentifier,
|
||
description: selectedDocument.value.description,
|
||
});
|
||
documentDialogMode.value = "edit";
|
||
documentState.value = "ready";
|
||
documentError.value = "";
|
||
};
|
||
const selectDocumentType = (event) => {
|
||
documentForm.documentType = documentTypeOptions[Number(event.detail.value)]?.value || "id_card";
|
||
};
|
||
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;
|
||
}
|
||
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 = "";
|
||
try {
|
||
const createdDocument = await personDocumentApi.createPersonDocument(
|
||
props.genealogyId,
|
||
createPayload,
|
||
{ requestController: documentSaveRequestController },
|
||
);
|
||
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||
createdDocumentId = createdDocument.documentId;
|
||
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;
|
||
}
|
||
await open();
|
||
} catch (error) {
|
||
if (
|
||
createdDocumentId &&
|
||
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
|
||
!isRequestCancelled(error)
|
||
) {
|
||
await loadDocumentDetail(createdDocumentId, "");
|
||
if (
|
||
isCurrentDocumentContext(
|
||
documentPersonId,
|
||
createdDocumentId,
|
||
activeWorkflow,
|
||
)
|
||
) {
|
||
documentError.value =
|
||
"证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
|
||
} else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
|
||
documentState.value = "error";
|
||
documentError.value =
|
||
"证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
|
||
}
|
||
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 || !member.value?.canManageDocuments || documentState.value === "submitting") return;
|
||
if (!documentForm.documentTitle.trim()) {
|
||
documentError.value = "请填写证件名称。";
|
||
return;
|
||
}
|
||
documentState.value = "submitting";
|
||
documentError.value = "";
|
||
const documentPersonId = props.personId;
|
||
const documentId = selectedDocument.value.documentId;
|
||
const activeWorkflow = workflowVersion;
|
||
try {
|
||
const updatedDocument = await personDocumentApi.updatePersonDocument(
|
||
props.genealogyId,
|
||
documentId,
|
||
{ lineagePersonId: documentPersonId, ...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 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 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 || !member.value?.canManageDocuments || 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 || !member.value?.canManageDocuments || 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 || !member.value?.canManageDocuments) 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 || !member.value?.canManageDocuments) 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 || !member.value?.canManageDocuments) 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 (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-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-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; }
|
||
</style>
|