This commit is contained in:
owenchen committed 2026-10-17 21:08:10 +08:00
1 parent 282d8ce583
commit 28dd3ffebd
229 files changed
+5765 -4786

No files matched your search

@@ -8,6 +8,11 @@
* uploadFile 直传 DmsFile/UploadFile,FilePath 走请求头、form 字段名=原始文件名、
* filename=分片三段式物理名并同存 AvatarFilename);
* - 性别是 GenderPolicy 枚举,admin-form 无 select,用 #extra 行点开 option-picker 回写;
* - 身份证上传:#extra 里的身份证块,chooseImage 单图 → 单通道上传同源后端 DmsFile/UploadIdCardFile
* (FilePath 头=idcard 目录、form 字段 side=auto)→ 后端先落盘再 server-to-server 调同 docker 的
* OCR 服务 /api/v1/idcard/recognize,把识别结果回填进返回的 FamilyPersonDisplayData(前端不直连 OCR,避免浏览器跨域);
* 前端拿到返回后:常用名/性别/出生日期 回填各自真实列;民族/住址/身份证号/签发机关/有效期 表内无列、不落库,
* 故不显示为表单字段,而是格式化写入「简介」(introduction 列)随保存落库;
* - 只读门控:!IsCanAmend 且 Privacy!=='Y' 时只显「基本资料」卡、隐藏其余卡与修改/删除;
* - 保存按 CreatedBy 有无决定 Update / Insert。
* 旧页的新增入口在查询页即被注释,本页只承担 view/amend 两态(create 走快速建员弹层)。
@@ -32,6 +37,7 @@ const readonly = ref(true);
const routeId = ref("");
const genderPickerVisible = ref(false);
const uploading = ref(false);
const idcardUploading = ref(false);
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
const genderLabel = computed(() => getPolicyText("GenderPolicy", person.value.Gender));
@@ -216,6 +222,167 @@ function onClearAvatar(): void {
person.value.AvatarUrl = "";
}
/** 身份证 OCR 性别文本("男"/"女"/"male"/"female")反查 GenderPolicy 存储键:
* 先按 key、再按 label 精确,最后按含「男/女」模糊匹配,全落空返回空串(键由后端语言包决定,不写死) */
function matchGenderKey(text: string): string {
const value = text.trim();
if (!value) {
return "";
}
const options = getPolicyOptions("GenderPolicy");
const exact = options.find(o => o.key === value || o.label === value);
if (exact) {
return exact.key;
}
const lower = value.toLowerCase();
const wantMale = value.includes("男") || lower.includes("male");
const wantFemale = value.includes("女") || lower.includes("female");
if (!wantMale && !wantFemale) {
return "";
}
const hit = options.find((o) => {
const label = `${o.key} ${o.label}`.toLowerCase();
return (wantMale && (o.label.includes("男") || label.includes("male")))
|| (wantFemale && (o.label.includes("女") || label.includes("female")));
});
return hit?.key ?? "";
}
/** 出生日期归一为 yyyy-MM-dd(OCR 常给 "19791111",也兼容带分隔符);不足 8 位数字返回空串 */
function formatBirthToYmd(raw: string): string {
const digits = String(raw ?? "").replace(/\D/g, "");
if (digits.length < 8) {
return "";
}
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
/** 把后端 UploadIdCardFile 返回的识别信息(FamilyPersonDisplayData)回填到表单:
* 常用名/性别/出生日期有对应 family_person 列,直接回填(性别回传原始文本按 GenderPolicy 反查键,出生归一 yyyy-MM-dd);
* 民族/住址/身份证号/签发机关/有效期限 表里没有列、存不进库,故不作为表单字段显示,
* 而是格式化追加进「简介」(introduction 是真实列,能落库)。字段值兼容 PascalCase 与 camelCase;有任一写入返回 true */
function applyIdCardOcr(info: any): boolean {
if (!info || typeof info !== "object") {
return false;
}
const pick = (pascal: string, camel: string): unknown => info[pascal] ?? info[camel];
let filled = 0;
const name = String(pick("CommonName", "commonName") ?? "").trim();
if (name) {
person.value.CommonName = name;
filled += 1;
}
const genderKey = matchGenderKey(String(pick("Gender", "gender") ?? ""));
if (genderKey) {
person.value.Gender = genderKey;
filled += 1;
}
const ymd = formatBirthToYmd(String(pick("BirthDate", "birthDate") ?? ""));
if (ymd) {
(person.value as Record<string, any>).BirthDate = ymd;
filled += 1;
}
if (upsertIdCardIntro(info)) {
filled += 1;
}
return filled > 0;
}
/** 把无列的身份信息(民族/住址/身份证号/签发机关/有效期限)格式化成一个「身份证识别信息」块写入简介:
* 该块恒追加在简介末尾,重新识别时先截掉旧块再追加,避免重复堆叠;无任何可写项返回 false(不动简介) */
function upsertIdCardIntro(info: any): boolean {
const pick = (pascal: string, camel: string): unknown => info[pascal] ?? info[camel];
const labelMap: Array<[string, string, string]> = [
["nation", "Nation", "nation"],
["address", "Address", "address"],
["idNumber", "IdNumber", "idNumber"],
["issuer", "Issuer", "issuer"],
["validity", "Validity", "validity"],
];
const lines: string[] = [];
for (const [labelKey, pascal, camel] of labelMap) {
const val = String(pick(pascal, camel) ?? "").trim();
if (val) {
lines.push(`${t(`FamilyPersonPage.${labelKey}`)}: ${val}`);
}
}
if (!lines.length) {
return false;
}
const title = t("FamilyPersonPage.idcardIntroTitle");
const block = [title, ...lines].join("\n");
let intro = String(person.value.Introduction ?? "");
const idx = intro.indexOf(title);
if (idx >= 0) {
// 旧识别块恒在末尾:截掉旧块(含其后内容)后再追加新块,避免重复
intro = intro.slice(0, idx).replace(/\s+$/, "");
}
person.value.Introduction = intro ? `${intro}\n\n${block}` : block;
return true;
}
/** 上传身份证:单图 → 同源后端 DmsFile/UploadIdCardFile(后端落盘 idcard 目录 + server-to-server 调 OCR)
* → 用返回的识别信息回填字段;前端不直连 OCR,避免浏览器跨域 */
async function onUploadIdCard(): Promise<void> {
if (idcardUploading.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `idcard-${Date.now()}.jpg`);
if (!filePath) {
return;
}
try {
idcardUploading.value = true;
// 单通道:本地预览先填,物理名(分片三段式)与头像 AvatarFilename 同口径
const physicalName = buildChunkFileName(fileName);
person.value.IdCardFilename = physicalName;
person.value.IdCardUrl = filePath;
// 上传到后端:FilePath 头=idcard 目录,side=auto;后端落盘后调 OCR 并把识别结果放进返回体
const result = await uploadFile({
url: "/DmsFile/UploadIdCardFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: "idcard" },
formData: { side: "auto" },
});
// 后端返回 FamilyPersonDisplayData,业务响应封装体在 Data/data;容错取内层
const recognized = result?.Data ?? result?.data ?? result;
if (applyIdCardOcr(recognized)) {
uni.showToast({ title: t("FamilyPersonPage.idcardUploadedTip"), icon: "none" });
}
else {
uni.showToast({ title: t("FamilyPersonPage.idcardNoResult"), icon: "none" });
}
}
catch (error) {
console.error(error);
uni.showToast({ title: t("FamilyPersonPage.idcardUploadFailed"), icon: "none" });
}
finally {
idcardUploading.value = false;
}
},
});
}
function onClearIdCard(): void {
if (person.value.IdCardUrl.startsWith("blob:") && typeof URL !== "undefined") {
URL.revokeObjectURL(person.value.IdCardUrl);
}
person.value.IdCardFilename = "";
person.value.IdCardUrl = "";
}
async function onSave(): Promise<void> {
if (!String(person.value.Surname ?? "").trim()) {
uni.showToast({ title: t("FamilyPersonPage.surnameRequired"), icon: "none" });
@@ -339,6 +506,32 @@ useAuthGuard(() => {
</view>
</view>
</view>
<!-- 身份证上传块(编辑态可传:后端落盘 idcard 目录并 OCR 后回填身份字段) -->
<view class="group">
<view class="group-card idcard-card">
<view class="idcard-head">
<text class="idcard-title">{{ t("FamilyPersonPage.idcardTitle") }}</text>
<text v-if="!ro && (person.IdCardUrl || person.IdCardFilename)" class="idcard-clear"
@tap="onClearIdCard">{{ t("FamilyPersonPage.idcardClear") }}</text>
</view>
<view class="idcard-thumb" :class="{ 'idcard-thumb--tap': !ro }" @tap="!ro && onUploadIdCard()">
<image v-if="person.IdCardUrl" class="idcard-thumb__img" :src="person.IdCardUrl"
mode="aspectFill" />
<view v-else class="idcard-thumb__empty">
<u-icon name="camera" size="40" color="#c4c8cf" />
<text class="idcard-thumb__empty-text">{{ t("FamilyPersonPage.idcardUpload") }}</text>
</view>
<view v-if="!ro" class="idcard-thumb__mask">
<text v-if="idcardUploading" class="idcard-thumb__mask-text">
{{ t("FamilyPersonPage.uploadingTip") }}
</text>
<u-icon v-else-if="person.IdCardUrl" name="camera" size="30" color="#ffffff" />
</view>
</view>
<text class="idcard-hint">{{ t("FamilyPersonPage.idcardHint") }}</text>
</view>
</view>
</template>
<option-picker :visible="genderPickerVisible" :title="t('FamilyPersonPage.gender')" :items="genderItems"
@@ -460,4 +653,86 @@ useAuthGuard(() => {
.enum-value-empty {
color: $u-tips-color;
}
/* 身份证上传块(编辑态可点,识别后回填身份字段) */
.idcard-card {
padding: 28rpx;
}
.idcard-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.idcard-title {
font-size: 28rpx;
color: $u-main-color;
}
.idcard-clear {
font-size: 24rpx;
color: $u-tips-color;
}
.idcard-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 320rpx;
overflow: hidden;
background-color: #f5f6f8;
border: 2rpx dashed #d6d9de;
border-radius: 16rpx;
}
.idcard-thumb--tap:active {
opacity: 0.7;
}
.idcard-thumb__img {
width: 100%;
height: 100%;
}
.idcard-thumb__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.idcard-thumb__empty-text {
font-size: 26rpx;
color: $u-tips-color;
}
.idcard-thumb__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
}
.idcard-thumb__mask-text {
font-size: 24rpx;
color: #ffffff;
}
.idcard-hint {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
line-height: 1.5;
color: $u-tips-color;
}
</style>