feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+79 -38
View File
@@ -22,7 +22,7 @@
label="新建证件档案"
@click="openDocumentCreate"
/>
<text v-if="!documents.length" class="document-dialog-empty">这位成员还没有可查看的重要证件</text>
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
<view
v-for="documentSummary in documents"
:key="documentSummary.documentId"
@@ -34,7 +34,7 @@
<view>
<text>{{ documentSummary.documentTitle }}</text>
<text
>{{ documentTypeLabel(documentSummary.documentType) }}{{
>{{ documentSummary.lineagePersonName ? `${documentSummary.lineagePersonName} · ` : "" }}{{ documentTypeLabel(documentSummary.documentType) }}{{
documentSummary.maskedIdentifier
? ` · ${documentSummary.maskedIdentifier}`
: ""
@@ -56,6 +56,7 @@
<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>
@@ -71,12 +72,12 @@
<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>
<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="member?.canManageDocuments" class="document-manage-actions">
<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
@@ -164,6 +165,15 @@
@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>
@@ -172,11 +182,12 @@ 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 ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
PERSON_DOCUMENT_RESOURCE_USAGE,
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS,
PERSON_DOCUMENT_TYPE_OPTIONS as documentTypeOptions
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS
} from "@/services/api/person-document-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
@@ -188,7 +199,7 @@ import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guar
const props = defineProps({
genealogyId: { type: String, required: true },
personId: { type: String, required: true },
personId: { type: String, default: "" },
member: { type: Object, default: null },
});
const emit = defineEmits(["busy-change", "transient-change"]);
@@ -197,6 +208,7 @@ 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("");
@@ -205,6 +217,8 @@ 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",
@@ -213,6 +227,7 @@ const documentForm = reactive({
description: "",
});
const documentListRequestController = createRequestController();
const documentTypeRequestController = createRequestController();
const documentDetailRequestController = createRequestController();
const documentDraftUploadRequestController = createRequestController();
const documentSaveRequestController = createRequestController();
@@ -239,7 +254,9 @@ const documentDialogTitle = computed(() => {
case "detail":
return selectedDocument.value?.documentTitle || "证件详情";
default:
return `${member.value?.name || "成员"}的重要证件`;
return props.personId
? `${member.value?.name || "成员"}的重要证件`
: "家谱重要证件";
}
});
const documentDialogMessage = computed(() => {
@@ -255,20 +272,16 @@ const documentDialogMessage = computed(() => {
}
});
const documentTypeLabel = (documentType) =>
({
id_card: "身份证",
household_register: "户口簿",
birth_certificate: "出生证明",
marriage_certificate: "结婚证",
other: "其他证件",
})[documentType] || "重要证件";
documentTypeOptions.value.find((option) => option.value === documentType)?.label ||
documents.value.find((document) => document.documentType === documentType)?.documentTypeLabel ||
"重要证件";
const documentTypeLabels = computed(() =>
documentTypeOptions.map((documentType) => documentType.label),
documentTypeOptions.value.map((documentType) => documentType.label),
);
const documentTypeIndex = computed(() =>
Math.max(
0,
documentTypeOptions.findIndex(
documentTypeOptions.value.findIndex(
(documentType) => documentType.value === documentForm.documentType,
),
),
@@ -280,11 +293,12 @@ const hasTransient = computed(() =>
documentDialogVisible.value ||
documentDeleteVisible.value ||
documentResourceDeleteTarget.value ||
documentPasswordDisableVisible.value,
documentPasswordDisableVisible.value ||
passwordRecoveryVisible.value,
),
);
const isBusy = computed(() =>
documentState.value === "loading" || documentState.value === "submitting",
documentState.value === "loading" || documentState.value === "submitting" || passwordRecoveryBusy.value,
);
const shouldIgnoreDocumentFailure = (error) => !isActive || isRequestCancelled(error);
const isCurrentPersonContext = (personId, activeWorkflow) =>
@@ -301,6 +315,7 @@ watch(isBusy, (value) => emit("busy-change", value), { immediate: true });
const resetDocumentWorkflow = () => {
workflowVersion += 1;
documentListRequestController.abort();
documentTypeRequestController.abort();
documentDetailRequestController.abort();
documentDraftUploadRequestController.abort();
documentSaveRequestController.abort();
@@ -315,6 +330,7 @@ const resetDocumentWorkflow = () => {
documentDeleteVisible.value = false;
documentResourceDeleteTarget.value = null;
documentPasswordDisableVisible.value = false;
passwordRecoveryVisible.value = false;
documentDialogMode.value = "list";
documentState.value = "idle";
documents.value = [];
@@ -336,13 +352,22 @@ const open = async () => {
selectedDocument.value = null;
documentAccessToken.value = "";
try {
const documentRows = await personDocumentApi.getPersonDocuments(
props.genealogyId,
{ lineagePersonId: props.personId },
{ requestController: documentListRequestController },
);
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;
documentTypeOptions.value = typeOptions;
documentState.value = "ready";
} catch (error) {
if (
@@ -363,9 +388,9 @@ const showDocumentList = () => {
documentError.value = "";
};
const openDocumentCreate = () => {
if (!member.value?.canCreateDocument) return;
if (!member.value?.canCreateDocument || !documentTypeOptions.value.length) return;
Object.assign(documentForm, {
documentType: "id_card",
documentType: documentTypeOptions.value.find((option) => option.default)?.value || documentTypeOptions.value[0].value,
documentTitle: "",
maskedIdentifier: "",
description: "",
@@ -376,19 +401,23 @@ const openDocumentCreate = () => {
documentError.value = "";
};
const openDocumentEdit = () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
const currentTypeAvailable = documentTypeOptions.value.some(
(option) => option.value === selectedDocument.value.documentType,
);
Object.assign(documentForm, {
documentType: selectedDocument.value.documentType,
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 = "";
documentError.value = currentTypeAvailable ? "" : "原证件类型已停用,请重新选择。";
};
const selectDocumentType = (event) => {
documentForm.documentType = documentTypeOptions[Number(event.detail.value)]?.value || "id_card";
documentForm.documentType = documentTypeOptions.value[Number(event.detail.value)]?.value || "";
documentError.value = "";
};
const uploadDocumentImage = async () => {
if (documentState.value === "submitting") return;
@@ -527,7 +556,7 @@ const loadDocumentDetail = async (documentId, accessToken) => {
}
};
const updateDocument = async () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
if (!documentForm.documentTitle.trim()) {
documentError.value = "请填写证件名称。";
return;
@@ -535,13 +564,14 @@ const updateDocument = async () => {
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: documentPersonId, ...documentForm },
{ lineagePersonId: targetPersonId, ...documentForm },
{ requestController: documentSaveRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
@@ -600,6 +630,10 @@ const unlockDocument = async () => {
documentError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
}
};
const completePasswordRecovery = (newPassword) => {
documentPassword.value = newPassword;
documentError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openDocumentResource = async (resource) => {
if (!selectedDocument.value) return;
const documentPersonId = props.personId;
@@ -651,7 +685,7 @@ const openDocumentResource = async (resource) => {
}
};
const addDocumentResource = async () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
@@ -711,7 +745,7 @@ const addDocumentResource = async () => {
}
};
const replaceDocumentResource = async (resource) => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
@@ -753,7 +787,7 @@ const replaceDocumentResource = async (resource) => {
}
};
const requestDeleteDocumentResource = (resource) => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
documentResourceDeleteTarget.value = resource;
};
const confirmDeleteDocumentResource = async () => {
@@ -785,7 +819,7 @@ const confirmDeleteDocumentResource = async () => {
}
};
const openDocumentPassword = () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentError.value = "";
@@ -861,7 +895,7 @@ const handleDocumentConfirm = () => {
resetDocumentWorkflow();
};
const requestDeleteDocument = () => {
if (!selectedDocument.value?.canDelete || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canDelete) return;
documentDeleteVisible.value = true;
};
const confirmDeleteDocument = async () => {
@@ -891,6 +925,11 @@ const confirmDeleteDocument = async () => {
}
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (documentResourceDeleteTarget.value) {
documentResourceDeleteTarget.value = null;
return true;
@@ -973,6 +1012,8 @@ onUnmounted(() => {
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;