739 lines
27 KiB
Vue
739 lines
27 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 家族成员详情(查看 / 修改):旧 WebVue views/master/FamilyPersonOperation.vue 的移动端等价物
|
||
*
|
||
* 对齐旧逻辑:
|
||
* - FindByKey(PersonId) 取 FamilyPersonData,主对象 FamilyPerson;
|
||
* - 头像上传 file-path=FamilyPersonAvatar(旧 FileUpload 的移动端等价:chooseImage 单图 +
|
||
* 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 走快速建员弹层)。
|
||
*/
|
||
import type { AdminFormAction, AdminFormField, AdminFormSection } from "@/admin/admin-types";
|
||
import { onLoad } from "@dcloudio/uni-app";
|
||
import { useLocale } from "uview-pro";
|
||
import { computed, ref } from "vue";
|
||
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
|
||
import { useAuthGuard } from "@/composables";
|
||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||
import { FamilyPersonData } from "@/data/auto/familyPersonData";
|
||
import { smartBack } from "@/router/routes";
|
||
import familyPersonService from "@/service/master/familyPersonService";
|
||
|
||
const { t } = useLocale();
|
||
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
|
||
|
||
const data = ref<FamilyPersonData>(new FamilyPersonData());
|
||
const person = computed(() => data.value.FamilyPerson);
|
||
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));
|
||
|
||
/** 只读门控(旧页同款):无权修改且本人未公开详细资料时,只保留基本资料卡 */
|
||
const readonlyBasicOnly = computed(
|
||
() => routeId.value !== "" && !person.value.IsCanAmend && person.value.Privacy !== "Y",
|
||
);
|
||
|
||
const heroTitle = computed(
|
||
() => `${person.value.Surname ?? ""}${person.value.GenealogyName ?? ""}`,
|
||
);
|
||
const heroChips = computed(() => {
|
||
const chips: string[] = [];
|
||
if (person.value.PersonId) {
|
||
chips.push(`${t("FamilyPersonPage.personId")}: ${person.value.PersonId}`);
|
||
}
|
||
if (person.value.TreeId) {
|
||
chips.push(`${t("FamilyPersonPage.treeId")}: ${person.value.TreeId}`);
|
||
}
|
||
return chips;
|
||
});
|
||
|
||
const sections = computed<AdminFormSection[]>(() => {
|
||
const basic: AdminFormSection = {
|
||
title: t("FamilyPersonPage.basicTitle"),
|
||
fields: [
|
||
{ label: t("FamilyPersonPage.surname"), prop: "Surname", required: true },
|
||
{ label: t("FamilyPersonPage.genealogyName"), prop: "GenealogyName", required: true },
|
||
{ label: t("FamilyPersonPage.commonName"), prop: "CommonName" },
|
||
{ label: t("FamilyPersonPage.alias"), prop: "Alias" },
|
||
{ label: t("FamilyPersonPage.zi"), prop: "Zi" },
|
||
{ label: t("FamilyPersonPage.hao"), prop: "Hao" },
|
||
{ label: t("FamilyPersonPage.birthOrder"), prop: "BirthOrder" },
|
||
],
|
||
};
|
||
if (readonlyBasicOnly.value) {
|
||
return [basic];
|
||
}
|
||
return [
|
||
basic,
|
||
{
|
||
title: t("FamilyPersonPage.familyTitle"),
|
||
fields: [
|
||
{ label: t("FamilyPersonPage.maritalStatus"), prop: "MaritalStatus" },
|
||
{ label: t("FamilyPersonPage.fatherId"), prop: "FatherId" },
|
||
{ label: t("FamilyPersonPage.motherName"), prop: "MotherName" },
|
||
{ label: t("FamilyPersonPage.school"), prop: "School" },
|
||
{ label: t("FamilyPersonPage.major"), prop: "Major" },
|
||
{ label: t("FamilyPersonPage.education"), prop: "Education" },
|
||
{ label: t("FamilyPersonPage.graduationStatus"), prop: "GraduationStatus" },
|
||
{ label: t("FamilyPersonPage.duty"), prop: "Duty" },
|
||
],
|
||
},
|
||
{
|
||
title: t("FamilyPersonPage.workTitle"),
|
||
fields: [
|
||
{ label: t("FamilyPersonPage.title"), prop: "Title" },
|
||
{
|
||
label: t("FamilyPersonPage.privacy"),
|
||
prop: "Privacy",
|
||
type: "switch",
|
||
display: model => (model.Privacy === "Y" ? t("FamilyPersonPage.yes") : t("FamilyPersonPage.no")),
|
||
},
|
||
{ label: t("FamilyPersonPage.liveStatus"), prop: "LiveStatus" },
|
||
{ label: t("FamilyPersonPage.bindUserId"), prop: "BindUserId" },
|
||
{
|
||
label: t("FamilyPersonPage.bindUser"),
|
||
prop: "IsBindUser",
|
||
type: "switch",
|
||
display: model => (model.IsBindUser === "Y" ? t("FamilyPersonPage.yes") : t("FamilyPersonPage.no")),
|
||
},
|
||
{ label: t("FamilyPersonPage.birthText"), prop: "BirthText" },
|
||
{ label: t("FamilyPersonPage.birthDate"), prop: "BirthDate", type: "date", date: true },
|
||
{ label: t("FamilyPersonPage.deathText"), prop: "DeathText" },
|
||
{ label: t("FamilyPersonPage.deathDate"), prop: "DeathDate", type: "date", date: true },
|
||
],
|
||
},
|
||
{
|
||
title: t("FamilyPersonPage.introTitle"),
|
||
fields: [
|
||
{
|
||
label: t("FamilyPersonPage.introduction"),
|
||
prop: "Introduction",
|
||
type: "textarea",
|
||
maxlength: 2000,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
});
|
||
|
||
const actions = computed<AdminFormAction[]>(() => {
|
||
const list: AdminFormAction[] = [];
|
||
if (readonlyBasicOnly.value) {
|
||
return list;
|
||
}
|
||
list.push({ key: "amend", label: t("AdminPage.amendBut"), when: "view" });
|
||
// 旧页口径:仅未挂族谱(TreeId 为空)的成员允许删除
|
||
if (person.value.TreeId === "") {
|
||
list.push({
|
||
key: "delete",
|
||
label: t("AdminPage.deleteBut"),
|
||
tone: "error",
|
||
confirm: true,
|
||
when: "view",
|
||
});
|
||
}
|
||
list.push({ key: "save", label: t("AdminPage.saveBut"), when: "edit" });
|
||
list.push({ key: "cancel", label: t("AdminPage.cancelBut"), when: "edit" });
|
||
return list;
|
||
});
|
||
|
||
async function load(): Promise<void> {
|
||
const result = await familyPersonService.FindByKey(routeId.value);
|
||
if (!result?.FamilyPerson?.PersonId) {
|
||
uni.showToast({ title: t("AdminPage.notExistTip"), icon: "none" });
|
||
return;
|
||
}
|
||
data.value = result;
|
||
}
|
||
|
||
function onFieldChange(prop: string, value: unknown): void {
|
||
if (prop === "Privacy" || prop === "IsBindUser") {
|
||
person.value[prop] = value ? "Y" : "N";
|
||
return;
|
||
}
|
||
(person.value as Record<string, any>)[prop] = value;
|
||
}
|
||
|
||
function onGenderConfirm(key: string): void {
|
||
person.value.Gender = key;
|
||
genderPickerVisible.value = false;
|
||
}
|
||
|
||
/** 头像上传:单图 -> DmsFile/UploadFile(旧 FileUpload 默认通道,FilePath 头指定 FamilyPersonAvatar)。
|
||
* 后端按 {Backup}/{FilePath}/{ext}/{分片名} 落盘,FindByKey 的 AvatarUrl 也按此拼 URL,
|
||
* 故 AvatarFilename 必须存分片物理名(buildChunkFileName),不能存原始文件名 */
|
||
async function onUploadAvatar(): Promise<void> {
|
||
if (uploading.value) {
|
||
return;
|
||
}
|
||
uni.chooseImage({
|
||
count: 1,
|
||
success: async (res: any) => {
|
||
const filePath = String(res?.tempFilePaths?.[0] ?? "");
|
||
const fileName = String(res?.tempFiles?.[0]?.name || `avatar-${Date.now()}.png`);
|
||
if (!filePath) {
|
||
return;
|
||
}
|
||
try {
|
||
uploading.value = true;
|
||
const physicalName = buildChunkFileName(fileName);
|
||
await uploadFile({
|
||
url: "/DmsFile/UploadFile",
|
||
filePath,
|
||
name: fileName,
|
||
fileName: physicalName,
|
||
header: { FilePath: "FamilyPersonAvatar" },
|
||
});
|
||
person.value.AvatarFilename = physicalName;
|
||
person.value.AvatarUrl = filePath;
|
||
uni.showToast({ title: t("FamilyPersonPage.avatarUploadedTip"), icon: "none" });
|
||
}
|
||
catch (error) {
|
||
console.error(error);
|
||
uni.showToast({ title: t("FamilyPersonPage.avatarUploadFailed"), icon: "none" });
|
||
}
|
||
finally {
|
||
uploading.value = false;
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
function onClearAvatar(): void {
|
||
// H5 本地预览是 blob: URL,清掉引用防泄漏;其余端是本地临时路径,无需回收
|
||
if (person.value.AvatarUrl.startsWith("blob:") && typeof URL !== "undefined") {
|
||
URL.revokeObjectURL(person.value.AvatarUrl);
|
||
}
|
||
person.value.AvatarFilename = "";
|
||
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" });
|
||
return;
|
||
}
|
||
if (!String(person.value.GenealogyName ?? "").trim()) {
|
||
uni.showToast({ title: t("FamilyPersonPage.genealogyNameRequired"), icon: "none" });
|
||
return;
|
||
}
|
||
const isUpdate = String(person.value.CreatedBy ?? "").trim() !== "";
|
||
const result = await (isUpdate
|
||
? familyPersonService.Update(data.value)
|
||
: familyPersonService.Insert(data.value));
|
||
if (result?.FamilyPerson?.PersonId) {
|
||
data.value = result;
|
||
routeId.value = result.FamilyPerson.PersonId;
|
||
}
|
||
readonly.value = true;
|
||
uni.showToast({ title: t("AdminPage.savedTip"), icon: "none" });
|
||
}
|
||
|
||
async function onDelete(): Promise<void> {
|
||
await familyPersonService.Delete(routeId.value);
|
||
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
|
||
setTimeout(() => smartBack("mineTab"), 600);
|
||
}
|
||
|
||
function onAction(action: AdminFormAction): void {
|
||
switch (action.key) {
|
||
case "amend":
|
||
readonly.value = false;
|
||
break;
|
||
case "save":
|
||
onSave();
|
||
break;
|
||
case "delete":
|
||
onDelete();
|
||
break;
|
||
case "cancel":
|
||
if (!routeId.value) {
|
||
smartBack("mineTab");
|
||
return;
|
||
}
|
||
readonly.value = true;
|
||
load();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
function onBack(): void {
|
||
smartBack("mineTab");
|
||
}
|
||
|
||
onLoad((options) => {
|
||
routeId.value = String(options?.id ?? "");
|
||
const mode = String(options?.mode ?? "view");
|
||
readonly.value = mode !== "create" && mode !== "amend";
|
||
});
|
||
|
||
let booted = false;
|
||
useAuthGuard(() => {
|
||
if (booted) {
|
||
return;
|
||
}
|
||
booted = true;
|
||
if (routeId.value) {
|
||
load();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<admin-form :nav-title="t('FamilyPersonPage.detailTitle')" :show-nav-back="true" :hero-title="heroTitle"
|
||
:hero-chips="heroChips" :sections="sections" :model="person" :readonly="readonly" :actions="actions"
|
||
@back="onBack" @action="onAction" @field-change="onFieldChange">
|
||
<!-- 旧系统同款:头像就在 hero 卡左侧,显示与上传同一处;编辑态右侧出「清除头像」 -->
|
||
<template #hero-side="{ readonly: ro }">
|
||
<view class="hero-avatar" @tap="!ro && onUploadAvatar()">
|
||
<image v-if="person.AvatarUrl" class="hero-avatar__img" :src="person.AvatarUrl" mode="aspectFill" />
|
||
<u-icon v-else name="account" size="56" color="#ffffff" />
|
||
<view v-if="!ro" class="hero-avatar__mask">
|
||
<text v-if="uploading" class="hero-avatar__mask-text">
|
||
{{ t("FamilyPersonPage.uploadingTip") }}
|
||
</text>
|
||
<u-icon v-else name="camera" size="28" color="#ffffff" />
|
||
</view>
|
||
</view>
|
||
</template>
|
||
<template #hero-end="{ readonly: ro }">
|
||
<view v-if="!ro && (person.AvatarUrl || person.AvatarFilename)" class="hero-clear" @tap="onClearAvatar">
|
||
<text class="hero-clear-text">
|
||
{{ t("FamilyPersonPage.clearAvatar") }}
|
||
</text>
|
||
</view>
|
||
</template>
|
||
<template #extra="{ readonly: ro }">
|
||
<!-- 只读门控提示:无修改权限且对方未公开详细资料 -->
|
||
<view v-if="readonlyBasicOnly" class="ro-tip">
|
||
<text>
|
||
{{ t("FamilyPersonPage.readonlyTip") }}
|
||
</text>
|
||
</view>
|
||
|
||
<template v-else>
|
||
<!-- 性别(admin-form 无 select,枚举行在这里点开的 option-picker 里回写) -->
|
||
<view class="group">
|
||
<view class="group-card">
|
||
<view class="enum-row" :class="{ 'enum-row--tap': !ro }"
|
||
@tap="!ro && (genderPickerVisible = true)">
|
||
<text class="enum-label">
|
||
{{ t("FamilyPersonPage.gender") }}
|
||
</text>
|
||
<view class="enum-value-box">
|
||
<text class="enum-value" :class="{ 'enum-value-empty': !genderLabel }">
|
||
{{ genderLabel || t("FamilyPersonPage.genderPlaceholder") }}
|
||
</text>
|
||
<u-icon v-if="!ro" name="arrow-right" size="24" color="#c4c8cf" />
|
||
</view>
|
||
</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"
|
||
:empty-text="t('FamilyPersonPage.genderEmpty')" :confirm-text="t('AdminPage.confirmBut')"
|
||
@close="genderPickerVisible = false" @confirm="onGenderConfirm" />
|
||
</template>
|
||
</admin-form>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.ro-tip {
|
||
padding: 28rpx;
|
||
margin-bottom: 28rpx;
|
||
font-size: 24rpx;
|
||
color: $u-tips-color;
|
||
background-color: #ffffff;
|
||
border-radius: 20rpx;
|
||
}
|
||
|
||
.group {
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.group-head {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 12rpx 12rpx;
|
||
}
|
||
|
||
.group-title {
|
||
font-size: 24rpx;
|
||
color: $u-tips-color;
|
||
}
|
||
|
||
.group-card {
|
||
padding: 0 28rpx;
|
||
overflow: hidden;
|
||
background-color: #ffffff;
|
||
border-radius: 20rpx;
|
||
}
|
||
|
||
/* hero 左侧头像(旧系统 ft-hero-avatar 同款):显示与上传同一处,编辑态覆相机遮罩 */
|
||
.hero-avatar {
|
||
position: relative;
|
||
display: flex;
|
||
flex: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
overflow: hidden;
|
||
background-color: rgba(255, 255, 255, 0.18);
|
||
border: 4rpx solid rgba(255, 255, 255, 0.55);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.hero-avatar__img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.hero-avatar__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);
|
||
}
|
||
|
||
.hero-avatar__mask-text {
|
||
font-size: 20rpx;
|
||
color: #ffffff;
|
||
}
|
||
|
||
/* hero 右侧「清除头像」白胶囊(旧系统同款位) */
|
||
.hero-clear {
|
||
flex: none;
|
||
padding: 10rpx 24rpx;
|
||
background-color: rgba(255, 255, 255, 0.22);
|
||
border-radius: 999rpx;
|
||
}
|
||
|
||
.hero-clear-text {
|
||
font-size: 24rpx;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.enum-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
min-height: 92rpx;
|
||
}
|
||
|
||
.enum-label {
|
||
font-size: 28rpx;
|
||
color: $u-main-color;
|
||
}
|
||
|
||
.enum-value-box {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.enum-value {
|
||
font-size: 26rpx;
|
||
color: $u-content-color;
|
||
}
|
||
|
||
.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>
|