Files
Docker/uniapp/youjiala/src/components/person-editor/person-editor.vue
T
2026-10-19 13:36:58 +08:00

952 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>