952 lines
34 KiB
Vue
952 lines
34 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 家族成员编辑器(底部抽屉,新增 / 修改共用)
|
||
*
|
||
* 把独立详情页 family-person-operation 的完整表单(基本 / 家庭 / 工作 / 简介 + 头像上传 + 身份证 OCR 回填)
|
||
* 收进一个弹层,供族谱页「新增夫妻 / 孩子 / 祖先」与关系行「修改」复用;创建走 Insert、编辑走 Update(按 CreatedBy 判定,旧页同款)。
|
||
* 组件自带数据与保存:create 用 props.treeId 落谱(可为空,祖先/新谱未落库时先建人再挂关系),
|
||
* edit 用 props.personId 经 FindByKey 载入。保存成功 emit("saved", person) 交调用方回填 / 刷新。
|
||
* 性别是 GenderPolicy 枚举,用 option-picker 单选;头像 / 身份证上传口径与详情页完全一致。
|
||
*/
|
||
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
|
||
import { useLocale } from "uview-pro";
|
||
import { computed, ref, watch } from "vue";
|
||
import { datePart } from "@/admin/admin-date";
|
||
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
|
||
import { useThemeTokens } from "@/composables";
|
||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||
import { FamilyPersonData } from "@/data/auto/familyPersonData";
|
||
import familyPersonService from "@/service/master/familyPersonService";
|
||
|
||
/** 单个字段描述:type 缺省为文本;switch/date/textarea/gender 走各自控件 */
|
||
interface FieldDef {
|
||
prop: string;
|
||
label: string;
|
||
type?: "switch" | "date" | "textarea" | "gender";
|
||
required?: boolean;
|
||
maxlength?: number;
|
||
}
|
||
|
||
const props = defineProps({
|
||
visible: { type: Boolean, default: false },
|
||
/** create=新增(Insert)/ edit=修改(Update) */
|
||
mode: { type: String as () => "create" | "edit", default: "create" },
|
||
/** edit 模式要载入并修改的成员主键 */
|
||
personId: { type: String, default: "" },
|
||
/** create 模式落到的族谱编号(可为空:祖先 / 新谱未落库时先建人) */
|
||
treeId: { type: String, default: "" },
|
||
/** create 模式姓的默认值(新增孩子传当前族员的姓;身份证 OCR 也按此姓拆分姓名) */
|
||
defaultSurname: { type: String, default: "" },
|
||
title: { type: String, default: "" },
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
(e: "update:visible", value: boolean): void;
|
||
(e: "saved", person: FamilyPersonDisplayData): void;
|
||
}>();
|
||
|
||
const { t } = useLocale();
|
||
// 主色:确认钮 / 勾选 / 开关随主题(渐变浅档白字发糊,用纯色)
|
||
const { primary } = useThemeTokens();
|
||
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
|
||
|
||
const data = ref<FamilyPersonData>(new FamilyPersonData());
|
||
const person = computed(() => data.value.FamilyPerson);
|
||
|
||
const saving = ref(false);
|
||
const uploading = ref(false);
|
||
const idcardUploading = ref(false);
|
||
const genderPickerVisible = ref(false);
|
||
|
||
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
|
||
const genderLabel = computed(() => getPolicyText("GenderPolicy", person.value.Gender));
|
||
|
||
/** 弹层内四分区 tab(与 sections 一一对应)与基本区「更多资料」展开态:每次打开复位 */
|
||
const activeTab = ref(0);
|
||
const moreExpanded = ref(false);
|
||
/** 基本区收进「更多资料」的字段:别名/字/号/排行,默认收起不占主表单 */
|
||
const BASIC_MORE_PROPS = ["Alias", "Zi", "Hao", "BirthOrder"];
|
||
/** hero 名字行:姓+谱名实时拼接(未填时模板里回退到标题/用途名) */
|
||
const heroName = computed(() => `${person.value.Surname ?? ""}${person.value.GenealogyName ?? ""}`);
|
||
/** 当前 tab 要渲染的字段:基本区收起时剩主四行(姓/谱名/性别/常用名) */
|
||
const visibleFields = computed<FieldDef[]>(() => {
|
||
const fields = sections.value[activeTab.value]?.fields ?? [];
|
||
if (activeTab.value !== 0 || moreExpanded.value) {
|
||
return fields;
|
||
}
|
||
return fields.filter(f => !BASIC_MORE_PROPS.includes(f.prop));
|
||
});
|
||
/** 「更多资料」行右侧灰字提示:收起字段的 label 连写(别名 · 字 · 号 · 排行) */
|
||
const moreHint = computed(() =>
|
||
(sections.value[0]?.fields ?? [])
|
||
.filter(f => BASIC_MORE_PROPS.includes(f.prop))
|
||
.map(f => f.label)
|
||
.join(" · "),
|
||
);
|
||
|
||
/** 分区字段配置:字段与详情页同口径;title 仅作分段器短标签(tabXxx),与旧页分区标题解耦 */
|
||
const sections = computed<Array<{ title: string; fields: FieldDef[] }>>(() => [
|
||
{
|
||
title: t("FamilyPersonPage.tabBasic"),
|
||
fields: [
|
||
{ prop: "Surname", label: t("FamilyPersonPage.surname"), required: true },
|
||
{ prop: "GenealogyName", label: t("FamilyPersonPage.genealogyName"), required: true },
|
||
{ prop: "Gender", label: t("FamilyPersonPage.gender"), type: "gender" },
|
||
{ prop: "CommonName", label: t("FamilyPersonPage.commonName") },
|
||
{ prop: "Alias", label: t("FamilyPersonPage.alias") },
|
||
{ prop: "Zi", label: t("FamilyPersonPage.zi") },
|
||
{ prop: "Hao", label: t("FamilyPersonPage.hao") },
|
||
{ prop: "BirthOrder", label: t("FamilyPersonPage.birthOrder") },
|
||
],
|
||
},
|
||
{
|
||
title: t("FamilyPersonPage.tabFamily"),
|
||
fields: [
|
||
{ prop: "MaritalStatus", label: t("FamilyPersonPage.maritalStatus") },
|
||
{ prop: "FatherId", label: t("FamilyPersonPage.fatherId") },
|
||
{ prop: "MotherName", label: t("FamilyPersonPage.motherName") },
|
||
{ prop: "School", label: t("FamilyPersonPage.school") },
|
||
{ prop: "Major", label: t("FamilyPersonPage.major") },
|
||
{ prop: "Education", label: t("FamilyPersonPage.education") },
|
||
{ prop: "GraduationStatus", label: t("FamilyPersonPage.graduationStatus") },
|
||
{ prop: "Duty", label: t("FamilyPersonPage.duty") },
|
||
],
|
||
},
|
||
{
|
||
title: t("FamilyPersonPage.tabWork"),
|
||
fields: [
|
||
{ prop: "Title", label: t("FamilyPersonPage.title") },
|
||
{ prop: "LiveStatus", label: t("FamilyPersonPage.liveStatus") },
|
||
{ prop: "BindUserId", label: t("FamilyPersonPage.bindUserId") },
|
||
{ prop: "IsBindUser", label: t("FamilyPersonPage.bindUser"), type: "switch" },
|
||
{ prop: "BirthText", label: t("FamilyPersonPage.birthText") },
|
||
{ prop: "BirthDate", label: t("FamilyPersonPage.birthDate"), type: "date" },
|
||
{ prop: "DeathText", label: t("FamilyPersonPage.deathText") },
|
||
{ prop: "DeathDate", label: t("FamilyPersonPage.deathDate"), type: "date" },
|
||
],
|
||
},
|
||
{
|
||
title: t("FamilyPersonPage.tabIntro"),
|
||
fields: [
|
||
{ prop: "Introduction", label: t("FamilyPersonPage.introduction"), type: "textarea", maxlength: 2000 },
|
||
],
|
||
},
|
||
]);
|
||
|
||
// 展开时按模式装载:edit 拉详情,create 重置并带上目标族谱号
|
||
watch(
|
||
() => props.visible,
|
||
(on) => {
|
||
if (!on) {
|
||
return;
|
||
}
|
||
if (props.mode === "edit" && props.personId) {
|
||
loadForEdit(props.personId);
|
||
}
|
||
else {
|
||
resetForCreate();
|
||
}
|
||
// 弹层重开永远从「基本资料」起,更多资料回到收起态
|
||
activeTab.value = 0;
|
||
moreExpanded.value = false;
|
||
},
|
||
);
|
||
|
||
function resetForCreate(): void {
|
||
data.value = new FamilyPersonData();
|
||
data.value.FamilyPerson.TreeId = props.treeId || "";
|
||
// 新增孩子姓随亲(父/母):预填传入的默认姓,用户可改
|
||
data.value.FamilyPerson.Surname = props.defaultSurname || "";
|
||
}
|
||
|
||
async function loadForEdit(id: string): Promise<void> {
|
||
try {
|
||
const result = await familyPersonService.FindByKey(id);
|
||
if (result?.FamilyPerson?.PersonId) {
|
||
data.value = result;
|
||
}
|
||
else {
|
||
uni.showToast({ title: t("AdminPage.notExistTip"), icon: "none" });
|
||
}
|
||
}
|
||
catch (error) {
|
||
console.error(error);
|
||
uni.showToast({ title: t("FilePage.actionFailed"), icon: "none" });
|
||
}
|
||
}
|
||
|
||
function close(): void {
|
||
emit("update:visible", false);
|
||
}
|
||
|
||
/** 取字段的字符串值(Date/布尔等统一转串供控件绑定;null/undefined 归空串) */
|
||
function fv(prop: string): string {
|
||
const raw = (person.value as Record<string, any>)[prop];
|
||
return raw === null || raw === undefined ? "" : String(raw);
|
||
}
|
||
|
||
function setField(prop: string, value: unknown): void {
|
||
(person.value as Record<string, any>)[prop] = value;
|
||
}
|
||
|
||
function onFieldRowTap(field: FieldDef): void {
|
||
if (field.type === "gender") {
|
||
genderPickerVisible.value = true;
|
||
}
|
||
}
|
||
|
||
function onGenderConfirm(key: string): void {
|
||
person.value.Gender = key;
|
||
genderPickerVisible.value = false;
|
||
}
|
||
|
||
/** 头像上传:单图 -> DmsFile/UploadFile(FilePath 头 FamilyPersonAvatar);AvatarFilename 存分片物理名,与详情页同口径 */
|
||
function onUploadAvatar(): 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 {
|
||
if (person.value.AvatarUrl.startsWith("blob:") && typeof URL !== "undefined") {
|
||
URL.revokeObjectURL(person.value.AvatarUrl);
|
||
}
|
||
person.value.AvatarFilename = "";
|
||
person.value.AvatarUrl = "";
|
||
}
|
||
|
||
/** 身份证 OCR 性别文本反查 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 返回的识别信息回填:常用名/性别/出生日期回填各自列,其余无列项写入简介;有任一写入返回 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 surname = String(person.value.Surname ?? "").trim();
|
||
const rest = surname && name.startsWith(surname) ? name.slice(surname.length).trim() : "";
|
||
if (rest && !String(person.value.GenealogyName ?? "").trim()) {
|
||
person.value.GenealogyName = rest;
|
||
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) {
|
||
setField("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)-> 回填 */
|
||
function onUploadIdCard(): 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;
|
||
const physicalName = buildChunkFileName(fileName);
|
||
person.value.IdCardFilename = physicalName;
|
||
person.value.IdCardUrl = filePath;
|
||
const result = await uploadFile({
|
||
url: "/DmsFile/UploadIdCardFile",
|
||
filePath,
|
||
name: fileName,
|
||
fileName: physicalName,
|
||
header: { FilePath: "idcard" },
|
||
formData: { side: "auto" },
|
||
});
|
||
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 = "";
|
||
}
|
||
|
||
/** 保存:姓 / 谱名必填;按 CreatedBy 有无决定 Update / Insert;成功回传 person 并关闭 */
|
||
async function onSave(): Promise<void> {
|
||
if (saving.value) {
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
try {
|
||
saving.value = true;
|
||
const isUpdate = String(person.value.CreatedBy ?? "").trim() !== "";
|
||
const result = await (isUpdate
|
||
? familyPersonService.Update(data.value)
|
||
: familyPersonService.Insert(data.value));
|
||
const saved = result?.FamilyPerson || data.value.FamilyPerson;
|
||
uni.showToast({ title: t("AdminPage.savedTip"), icon: "none" });
|
||
emit("saved", saved);
|
||
close();
|
||
}
|
||
catch (error) {
|
||
console.error(error);
|
||
uni.showToast({ title: t("FilePage.actionFailed"), icon: "none" });
|
||
}
|
||
finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<u-popup :model-value="props.visible" mode="bottom" round="24"
|
||
:close-on-click-overlay="!saving && !uploading && !idcardUploading" @close="close">
|
||
<view class="pe">
|
||
<!-- hero(图示同款):左大圆头像点开选图,右侧名字实时拼接 + 谱名灰字 + 红字清除头像;新增未填名时回退用途标题 -->
|
||
<view class="pe__hero">
|
||
<view class="pe__avatar" @tap="onUploadAvatar">
|
||
<image v-if="person.AvatarUrl" class="pe__avatar-img" :src="person.AvatarUrl" mode="aspectFill" />
|
||
<u-icon v-else name="account" size="52" color="#c4c8cf" />
|
||
<view class="pe__avatar-mask">
|
||
<text v-if="uploading" class="pe__avatar-mask-text">
|
||
{{ t("FamilyPersonPage.uploadingTip") }}
|
||
</text>
|
||
<u-icon v-else name="camera" size="26" color="#ffffff" />
|
||
</view>
|
||
</view>
|
||
<view class="pe__hero-main">
|
||
<text class="pe__hero-name">{{ heroName || props.title || t('FamilyPersonPage.detailTitle') }}</text>
|
||
<text class="pe__hero-genealogy">{{ t("FamilyPersonPage.genealogyName") }} {{ person.GenealogyName || "-" }}</text>
|
||
<text v-if="person.AvatarUrl || person.AvatarFilename" class="pe__hero-clear" @tap="onClearAvatar">
|
||
{{ t("FamilyPersonPage.clearAvatar") }}
|
||
</text>
|
||
</view>
|
||
<!-- 公开资料开关从任职区移到 hero 右侧:一眼可见的整档隐私闸 -->
|
||
<view class="pe__hero-privacy">
|
||
<text class="pe__hero-privacy-label">{{ t("FamilyPersonPage.privacy") }}</text>
|
||
<u-switch :model-value="person.Privacy === 'Y'" :active-color="primary"
|
||
@change="setField('Privacy', $event ? 'Y' : 'N')" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 四分段器:一次只看一个分区(基本/家庭学业/任职状态/简介) -->
|
||
<view class="pe__tabs">
|
||
<view v-for="(section, index) in sections" :key="section.title" class="pe__tab"
|
||
:class="{ 'pe__tab--on': activeTab === index }" @tap="activeTab = index">
|
||
<text>{{ section.title }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view scroll-y class="pe__body">
|
||
<!-- 当前分区字段:一行一字段,标签左 / 控件右 -->
|
||
<view class="pe__card">
|
||
<view v-for="field in visibleFields" :key="field.prop" class="pe__row"
|
||
:class="{ 'pe__row--tap': field.type === 'gender', 'pe__row--area': field.type === 'textarea' }"
|
||
@tap="onFieldRowTap(field)">
|
||
<!-- 简介区不展 label:整卡只剩一块大输入区 -->
|
||
<view v-if="field.type !== 'textarea'" class="pe__label">
|
||
<text>{{ field.label }}</text>
|
||
<text v-if="field.required" class="pe__required">
|
||
*
|
||
</text>
|
||
</view>
|
||
<view class="pe__control">
|
||
<!-- 性别:枚举行点开 option-picker 回写 -->
|
||
<view v-if="field.type === 'gender'" class="pe__picker">
|
||
<text :class="{ 'pe__picker-empty': !genderLabel }">
|
||
{{ genderLabel || t('FamilyPersonPage.genderPlaceholder') }}
|
||
</text>
|
||
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
|
||
</view>
|
||
<u-switch v-else-if="field.type === 'switch'" :model-value="fv(field.prop) === 'Y'"
|
||
:active-color="primary" @change="setField(field.prop, $event ? 'Y' : 'N')" />
|
||
<textarea v-else-if="field.type === 'textarea'" class="pe__textarea"
|
||
:value="fv(field.prop)" :maxlength="field.maxlength ?? 2000"
|
||
:placeholder="t('FamilyPersonPage.tabIntro')"
|
||
@input="setField(field.prop, $event.detail.value)" />
|
||
<picker v-else-if="field.type === 'date'" mode="date" :value="datePart(fv(field.prop))"
|
||
@change="setField(field.prop, $event.detail.value)">
|
||
<view class="pe__picker">
|
||
<text :class="{ 'pe__picker-empty': !fv(field.prop) }">
|
||
{{ datePart(fv(field.prop)) || t('AdminPage.notSetTip') }}
|
||
</text>
|
||
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
|
||
</view>
|
||
</picker>
|
||
<input v-else class="pe__input" :value="fv(field.prop)"
|
||
:maxlength="field.maxlength ?? 100" :placeholder="field.label"
|
||
@input="setField(field.prop, $event.detail.value)">
|
||
</view>
|
||
</view>
|
||
<!-- 基本区「更多资料」展开行:绿链 + 收起字段灰字提示,点开就地展开/收起 -->
|
||
<view v-if="activeTab === 0" class="pe__row pe__row--tap" @tap="moreExpanded = !moreExpanded">
|
||
<text class="pe__more-link">{{ t("FamilyPersonPage.moreInfo") }}</text>
|
||
<view class="pe__picker">
|
||
<text class="pe__more-hint">{{ moreHint }}</text>
|
||
<u-icon :name="moreExpanded ? 'arrow-up' : 'arrow-down'" size="22" color="#8f959e" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 身份证上传块:仅基本区展示(图示同位),标题入卡内 -->
|
||
<view v-if="activeTab === 0" class="pe__group">
|
||
<view class="pe__card pe__idcard">
|
||
<text class="pe__idcard-title">
|
||
{{ t("FamilyPersonPage.idcardTitle") }}
|
||
</text>
|
||
<view class="pe__idcard-head">
|
||
<text class="pe__idcard-hint">
|
||
{{ t("FamilyPersonPage.idcardHint") }}
|
||
</text>
|
||
<text v-if="person.IdCardUrl || person.IdCardFilename" class="pe__idcard-clear"
|
||
@tap="onClearIdCard">
|
||
{{ t("FamilyPersonPage.idcardClear") }}
|
||
</text>
|
||
</view>
|
||
<view class="pe__idcard-thumb" @tap="onUploadIdCard">
|
||
<image v-if="person.IdCardUrl" class="pe__idcard-thumb-img" :src="person.IdCardUrl"
|
||
mode="aspectFill" />
|
||
<view v-else class="pe__idcard-empty">
|
||
<u-icon name="camera" size="40" color="#c4c8cf" />
|
||
<text class="pe__idcard-empty-text">
|
||
{{ t("FamilyPersonPage.idcardUpload") }}
|
||
</text>
|
||
</view>
|
||
<view class="pe__idcard-mask">
|
||
<text v-if="idcardUploading" class="pe__idcard-mask-text">
|
||
{{ t("FamilyPersonPage.uploadingTip") }}
|
||
</text>
|
||
<u-icon v-else-if="person.IdCardUrl" name="camera" size="30" color="#ffffff" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="pe__body-space" />
|
||
</scroll-view>
|
||
|
||
<view class="pe__footer">
|
||
<view class="pe__btn" @tap="close">
|
||
<text class="pe__btn-text">
|
||
{{ t("AdminPage.cancelBut") }}
|
||
</text>
|
||
</view>
|
||
<view class="pe__btn pe__btn--primary" :class="{ 'pe__btn--muted': saving }"
|
||
:style="{ background: primary }" @tap="onSave">
|
||
<text class="pe__btn-text pe__btn-text--primary">
|
||
{{ saving ? t("AdminPage.savingTip") : t("AdminPage.saveBut") }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
<view class="pe__safe" />
|
||
</view>
|
||
|
||
<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" />
|
||
</u-popup>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* 底部抽屉统一风格:浅灰面板底 + 白色圆角卡分块(同 option-picker / person-quick-create) */
|
||
.pe {
|
||
padding: 32rpx 24rpx 0;
|
||
background-color: #f2f3f5;
|
||
|
||
/* hero:左大圆头像 + 右侧名字/谱名/清除链,图示同款左对齐结构 */
|
||
&__hero {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 28rpx;
|
||
padding: 4rpx 12rpx 24rpx;
|
||
}
|
||
|
||
&__hero-main {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
min-width: 0;
|
||
}
|
||
|
||
&__hero-name {
|
||
overflow: hidden;
|
||
font-size: 40rpx;
|
||
font-weight: 700;
|
||
color: #1f2329;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
&__hero-genealogy {
|
||
font-size: 24rpx;
|
||
color: #8f959e;
|
||
}
|
||
|
||
&__hero-clear {
|
||
font-size: 24rpx;
|
||
color: #fa3534;
|
||
}
|
||
|
||
/* hero 右侧:公开资料开关,小灰字题注 + 开关竖排 */
|
||
&__hero-privacy {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
}
|
||
|
||
&__hero-privacy-label {
|
||
font-size: 22rpx;
|
||
color: #8f959e;
|
||
}
|
||
|
||
/* 四分段器:灰底轨道 + 选中白底胶囊(同详情页 tab 观感) */
|
||
&__tabs {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 8rpx;
|
||
padding: 8rpx;
|
||
margin-bottom: 24rpx;
|
||
background-color: #e8eaed;
|
||
border-radius: 999rpx;
|
||
}
|
||
|
||
&__tab {
|
||
display: flex;
|
||
flex: 1;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 68rpx;
|
||
padding: 0 8rpx;
|
||
border-radius: 999rpx;
|
||
|
||
text {
|
||
font-size: 24rpx;
|
||
color: #5c626b;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
&--on {
|
||
background-color: #ffffff;
|
||
box-shadow: 0 2rpx 8rpx rgba(31, 35, 41, 0.08);
|
||
|
||
text {
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 头像:hero 左侧大圆图,点开选图上传,上传中遮罩转文字 */
|
||
&__avatar {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 128rpx;
|
||
height: 128rpx;
|
||
overflow: hidden;
|
||
background-color: #ffffff;
|
||
border: 3rpx solid #e5e6eb;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
&__avatar-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
&__avatar-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
&__avatar-mask-text {
|
||
font-size: 20rpx;
|
||
color: #ffffff;
|
||
}
|
||
|
||
&__body {
|
||
/* 明确封顶高度:scroll-view 在 uni-app/H5 需确定高度才会内部滚动,否则撑满内容把底部钮顶出可视区(同 option-picker) */
|
||
max-height: 56vh;
|
||
}
|
||
|
||
&__group {
|
||
margin-top: 24rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
&__card {
|
||
padding: 0 28rpx;
|
||
overflow: hidden;
|
||
background-color: #ffffff;
|
||
border-radius: 20rpx;
|
||
}
|
||
|
||
/* 一行一字段:标签左、控件右,行间细线(末行无线) */
|
||
&__row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20rpx;
|
||
min-height: 92rpx;
|
||
padding: 16rpx 0;
|
||
border-bottom: 1rpx solid #eef0f3;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
&--tap {
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* 简介行:竖排整宽无标签,拉高输入区 */
|
||
&--area {
|
||
display: block;
|
||
|
||
.pe__textarea {
|
||
min-height: 360rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
&__label {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
font-size: 28rpx;
|
||
color: #1f2329;
|
||
}
|
||
|
||
&__required {
|
||
margin-left: 6rpx;
|
||
color: #ff4d4f;
|
||
}
|
||
|
||
&__control {
|
||
display: flex;
|
||
flex: 1;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
min-width: 0;
|
||
}
|
||
|
||
&__input {
|
||
width: 100%;
|
||
font-size: 28rpx;
|
||
color: #1f2329;
|
||
text-align: right;
|
||
}
|
||
|
||
&__textarea {
|
||
width: 100%;
|
||
min-height: 120rpx;
|
||
font-size: 28rpx;
|
||
line-height: 1.5;
|
||
color: #1f2329;
|
||
text-align: left;
|
||
}
|
||
|
||
&__picker {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8rpx;
|
||
font-size: 28rpx;
|
||
color: #1f2329;
|
||
}
|
||
|
||
&__picker-empty {
|
||
color: #8f959e;
|
||
}
|
||
|
||
/* 「更多资料」行:左绿链 + 右灰字字段提示 + 箭头 */
|
||
&__more-link {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #19be6b;
|
||
}
|
||
|
||
&__more-hint {
|
||
font-size: 24rpx;
|
||
color: #8f959e;
|
||
}
|
||
|
||
/* 身份证块 */
|
||
&__idcard {
|
||
padding: 28rpx;
|
||
}
|
||
|
||
&__idcard-title {
|
||
display: block;
|
||
margin-bottom: 12rpx;
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #1f2329;
|
||
}
|
||
|
||
&__idcard-head {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
&__idcard-hint {
|
||
flex: 1;
|
||
font-size: 22rpx;
|
||
color: #8f959e;
|
||
}
|
||
|
||
&__idcard-clear {
|
||
flex-shrink: 0;
|
||
margin-left: 16rpx;
|
||
font-size: 24rpx;
|
||
color: #fa3534;
|
||
}
|
||
|
||
&__idcard-thumb {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 260rpx;
|
||
overflow: hidden;
|
||
background-color: #f7f8fa;
|
||
border: 1rpx dashed #d4d7dd;
|
||
border-radius: 16rpx;
|
||
}
|
||
|
||
&__idcard-thumb-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
&__idcard-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
&__idcard-empty-text {
|
||
font-size: 24rpx;
|
||
color: #8f959e;
|
||
}
|
||
|
||
&__idcard-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
&__idcard-mask-text {
|
||
font-size: 22rpx;
|
||
color: #ffffff;
|
||
}
|
||
|
||
&__body-space {
|
||
height: 16rpx;
|
||
}
|
||
|
||
&__footer {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
gap: 20rpx;
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
/* 底部按键统一规格(同 option-picker):等高圆角矩形 + 细描边,非胶囊 */
|
||
&__btn {
|
||
display: flex;
|
||
flex: 1;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 96rpx;
|
||
border: 2rpx solid #e5e6eb;
|
||
border-radius: 24rpx;
|
||
background-color: #ffffff;
|
||
|
||
&--primary {
|
||
border-color: transparent;
|
||
}
|
||
|
||
&--muted {
|
||
opacity: 0.45;
|
||
}
|
||
}
|
||
|
||
&__btn-text {
|
||
font-size: 32rpx;
|
||
color: #5c626b;
|
||
|
||
&--primary {
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
}
|
||
}
|
||
|
||
&__safe {
|
||
flex-shrink: 0;
|
||
height: calc(32rpx + env(safe-area-inset-bottom));
|
||
}
|
||
}
|
||
</style>
|