ow
This commit is contained in:
1 parent
479762b57e
commit
27284040cc
30 files changed
+3042
-428
No files matched your search
@@ -136,9 +136,15 @@ function onSearch(): void {
|
||||
function toggleSelect(row: AdminRow): void {
|
||||
const key = keyOf(row);
|
||||
if (!props.multiple) {
|
||||
// 单选:直接确认
|
||||
emit("confirm", [row]);
|
||||
closePopup();
|
||||
// 单选:点行高亮选中(再点同一条取消),待「确定」回传——与多选同构,取消/确定栏常驻
|
||||
if (selectedKeys.value[0] === key) {
|
||||
selectedKeys.value = [];
|
||||
selectedPool.value = {};
|
||||
}
|
||||
else {
|
||||
selectedKeys.value = [key];
|
||||
selectedPool.value = { [key]: row };
|
||||
}
|
||||
return;
|
||||
}
|
||||
const idx = selectedKeys.value.indexOf(key);
|
||||
@@ -249,18 +255,21 @@ watch(
|
||||
<template v-else>
|
||||
<view v-for="row in rows" :key="keyOf(row)" class="pick-popup__row"
|
||||
:class="{ 'is-selected': isSelected(row) }" @tap="toggleSelect(row)">
|
||||
<!-- 多选勾选框 -->
|
||||
<view v-if="props.multiple" class="pick-popup__check" :class="{ 'is-on': isSelected(row) }">
|
||||
<!-- 选择指示:多选方勾、单选圆圈,选中绿底白勾 -->
|
||||
<view class="pick-popup__check" :class="{ 'is-on': isSelected(row), 'pick-popup__check--radio': !props.multiple }">
|
||||
<u-icon v-if="isSelected(row)" name="checkbox-mark" size="24" color="#ffffff" />
|
||||
</view>
|
||||
<view class="pick-popup__content">
|
||||
<text class="pick-popup__main">
|
||||
{{ getTitle(row) }}
|
||||
</text>
|
||||
<view v-for="col in getSubtitles()" :key="col.prop" class="pick-popup__sub">
|
||||
<text class="pick-popup__sub-label">{{ col.label }}</text>
|
||||
<text class="pick-popup__sub-value">{{ row[col.prop] ?? "" }}</text>
|
||||
</view>
|
||||
<!-- row 插槽:调用方可自绘行内容(如族谱页成员行:头像+姓名带性别+常用名);不传则走默认标题+副字段 -->
|
||||
<slot name="row" :row="row">
|
||||
<text class="pick-popup__main">
|
||||
{{ getTitle(row) }}
|
||||
</text>
|
||||
<view v-for="col in getSubtitles()" :key="col.prop" class="pick-popup__sub">
|
||||
<text class="pick-popup__sub-label">{{ col.label }}</text>
|
||||
<text class="pick-popup__sub-value">{{ row[col.prop] ?? "" }}</text>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -272,10 +281,10 @@ watch(
|
||||
</template>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏(多选时) -->
|
||||
<view v-if="props.multiple" class="pick-popup__actions">
|
||||
<view class="pick-popup__action-left" @tap="toggleSelectAll">
|
||||
<text class="pick-popup__toggle">
|
||||
<!-- 底部操作栏(单选/多选常驻:取消、确定交互统一) -->
|
||||
<view class="pick-popup__actions">
|
||||
<view class="pick-popup__action-left">
|
||||
<text v-if="props.multiple" class="pick-popup__toggle" @tap="toggleSelectAll">
|
||||
{{ selectedKeys.length >= rows.length && rows.length > 0 ? t("AdminPage.unselectAllBut") :
|
||||
t("AdminPage.selectAllBut") }}
|
||||
</text>
|
||||
@@ -379,6 +388,10 @@ $brand-green-light: #47a678;
|
||||
border-color: $brand-green;
|
||||
}
|
||||
|
||||
.pick-popup__check--radio {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pick-popup__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import type { PropType } from "vue";
|
||||
import type { FileItem, FileKind } from "@/data/custom/fileBrowser";
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useThemeTokens } from "@/composables/useThemeGradient";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -58,6 +58,18 @@ function visualOf(kind: FileKind): { icon: string; color: string } | null {
|
||||
return kind === "folder" ? null : kindVisual[kind];
|
||||
}
|
||||
|
||||
// 缩略图加载失败的卡记住失败态,回退类型图标(图被清理/静态目录无权限时不致白块)
|
||||
const thumbFailed = ref(new Set<number>());
|
||||
|
||||
function onThumbError(file: FileItem): void {
|
||||
thumbFailed.value.add(file.id);
|
||||
}
|
||||
|
||||
/** 可显缩略图的地址:仅图片类型且后端 Url(Scale 压缩图)有值、未报错 */
|
||||
function thumbSrcOf(file: FileItem): string {
|
||||
return file.kind === "image" && file.thumbUrl && !thumbFailed.value.has(file.id) ? file.thumbUrl : "";
|
||||
}
|
||||
|
||||
function isSelected(file: FileItem): boolean {
|
||||
return props.selectedIds.includes(file.id);
|
||||
}
|
||||
@@ -108,6 +120,9 @@ function onCardTap(file: FileItem): void {
|
||||
<view v-if="file.kind === 'folder'" class="fgl-card__folder" :style="folderStyle">
|
||||
<view class="fgl-card__folder-tab" :style="folderTabStyle" />
|
||||
</view>
|
||||
<!-- 图片卡:后端 Url 指向 Scale 原图压缩缩略(旧系统宫格同款),失败回退图标 -->
|
||||
<image v-else-if="thumbSrcOf(file)" class="fgl-card__thumb" :src="thumbSrcOf(file)"
|
||||
mode="aspectFill" @error="onThumbError(file)" />
|
||||
<u-icon v-else :name="visualOf(file.kind)!.icon" size="88" :color="visualOf(file.kind)!.color" />
|
||||
<!-- 锁仅目录显示(旧系统:文件不挂锁):绿开锁=有权限,灰闭锁=无权限;
|
||||
蓝分享标=已被收藏共享,两者文件/目录都可显示;
|
||||
@@ -235,6 +250,15 @@ function onCardTap(file: FileItem): void {
|
||||
border-radius: 10rpx 10rpx 0 0;
|
||||
}
|
||||
|
||||
/* 图片缩略图:尺寸对齐类型图标区(u-icon 88),圆角卡化 */
|
||||
.fgl-card__thumb {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
margin: 4rpx 0;
|
||||
border-radius: 14rpx;
|
||||
background-color: rgba(31, 35, 41, 0.04);
|
||||
}
|
||||
|
||||
.fgl-card__info {
|
||||
width: 100%;
|
||||
padding: 0 12rpx;
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
<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>
|
||||
@@ -1,259 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 快速新增家族成员(底部抽屉):旧 WebVue components/master/FamilyPersonQuickCreateModal.vue 的移动端等价物
|
||||
*
|
||||
* 族谱关系维护里「新增配偶 / 新增孩子 / 新增祖先」都调它:只填 姓* / 名(谱名)* / 性别*,
|
||||
* 落库走 FamilyPersonService.Insert(TreeId 由调用方带入),成功后 emit("created", person),
|
||||
* created 取 result?.FamilyPerson || payload.FamilyPerson,由页面按用途回填到对应关系列表。
|
||||
* 性别用 option-picker 单选(数据来自 GenderPolicy 语言包),与成员详情页同口径。
|
||||
*/
|
||||
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useThemeTokens } from "@/composables";
|
||||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||||
import { FamilyPersonData } from "@/data/auto/familyPersonData";
|
||||
import { FamilyPersonDisplayData as FamilyPersonDisplayDataCls } from "@/data/auto/familyPersonDisplayData";
|
||||
import familyPersonService from "@/service/master/familyPersonService";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
treeId: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "created", person: FamilyPersonDisplayData): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
// 确认钮纯色跟主题(渐变浅档白字发糊)
|
||||
const { primary } = useThemeTokens();
|
||||
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
|
||||
|
||||
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
|
||||
|
||||
const surname = ref("");
|
||||
const genealogyName = ref("");
|
||||
const gender = ref("");
|
||||
const saving = ref(false);
|
||||
const genderPickerVisible = ref(false);
|
||||
|
||||
const genderLabel = computed(() => getPolicyText("GenderPolicy", gender.value));
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(on) => {
|
||||
if (on) {
|
||||
surname.value = "";
|
||||
genealogyName.value = "";
|
||||
gender.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function close(): void {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function onGenderConfirm(key: string): void {
|
||||
gender.value = key;
|
||||
genderPickerVisible.value = false;
|
||||
}
|
||||
|
||||
async function onConfirm(): Promise<void> {
|
||||
if (!surname.value.trim()) {
|
||||
uni.showToast({ title: t("FamilyPersonPage.surnameRequired"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (!genealogyName.value.trim()) {
|
||||
uni.showToast({ title: t("FamilyPersonPage.genealogyNameRequired"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (!gender.value) {
|
||||
uni.showToast({ title: t("FamilyPersonPage.genderRequired"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = new FamilyPersonData();
|
||||
payload.FamilyPerson = new FamilyPersonDisplayDataCls();
|
||||
payload.FamilyPerson.Surname = surname.value.trim();
|
||||
payload.FamilyPerson.GenealogyName = genealogyName.value.trim();
|
||||
payload.FamilyPerson.Gender = gender.value;
|
||||
payload.FamilyPerson.TreeId = props.treeId || "";
|
||||
|
||||
try {
|
||||
saving.value = true;
|
||||
const result = await familyPersonService.Insert(payload);
|
||||
const created = result?.FamilyPerson || payload.FamilyPerson;
|
||||
emit("created", created);
|
||||
close();
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
uni.showToast({ title: t("FamilyPersonPage.quickCreateFailed"), icon: "none" });
|
||||
}
|
||||
finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-popup :model-value="props.visible" mode="bottom" round="24" :close-on-click-overlay="!saving" @close="close">
|
||||
<view class="pqc">
|
||||
<text class="pqc__title">
|
||||
{{ props.title || t('FamilyPersonPage.quickCreateTitle') }}
|
||||
</text>
|
||||
|
||||
<!-- 表单白卡(底部抽屉统一风格:灰底 + 白色圆角分块) -->
|
||||
<view class="pqc__card">
|
||||
<view class="pqc__field">
|
||||
<text class="pqc__label">
|
||||
{{ t("FamilyPersonPage.surname") }}<text class="pqc__required">*</text>
|
||||
</text>
|
||||
<u-input v-model="surname" :border="true" :maxlength="100"
|
||||
:placeholder="t('FamilyPersonPage.surnamePlaceholder')" />
|
||||
</view>
|
||||
|
||||
<view class="pqc__field">
|
||||
<text class="pqc__label">
|
||||
{{ t("FamilyPersonPage.genealogyName") }}<text class="pqc__required">*</text>
|
||||
</text>
|
||||
<u-input v-model="genealogyName" :border="true" :maxlength="100"
|
||||
:placeholder="t('FamilyPersonPage.genealogyNamePlaceholder')" />
|
||||
</view>
|
||||
|
||||
<view class="pqc__field">
|
||||
<text class="pqc__label">
|
||||
{{ t("FamilyPersonPage.gender") }}<text class="pqc__required">*</text>
|
||||
</text>
|
||||
<view class="pqc__picker" @tap="genderPickerVisible = true">
|
||||
<text :class="{ 'pqc__picker-empty': !genderLabel }">
|
||||
{{ genderLabel || t('FamilyPersonPage.genderPlaceholder') }}
|
||||
</text>
|
||||
<u-icon name="arrow-right" size="26" color="#c4c8cf" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pqc__footer">
|
||||
<view class="pqc__btn" @tap="close">
|
||||
<text class="pqc__btn-text">
|
||||
{{ t("AdminPage.cancelBut") }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="pqc__btn pqc__btn--primary" :class="{ 'pqc__btn--muted': saving }"
|
||||
:style="{ background: primary }" @tap="onConfirm">
|
||||
<text class="pqc__btn-text pqc__btn-text--primary">
|
||||
{{ saving ? t("AdminPage.savingTip") : t("AdminPage.confirmBut") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pqc__safe" />
|
||||
</view>
|
||||
|
||||
<option-picker :visible="genderPickerVisible" :title="t('FamilyPersonPage.gender')" :items="genderItems"
|
||||
:empty-text="t('FamilyPersonPage.genderEmpty')" @close="genderPickerVisible = false"
|
||||
@confirm="onGenderConfirm" />
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 底部抽屉统一风格:浅灰面板底 + 白色圆角卡分块 */
|
||||
.pqc {
|
||||
padding: 32rpx 24rpx 0;
|
||||
background-color: #f2f3f5;
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
padding-bottom: 24rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__card {
|
||||
padding: 28rpx 28rpx 4rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
&__field {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #646a73;
|
||||
}
|
||||
|
||||
&__required {
|
||||
margin-left: 2rpx;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&__picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 76rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
&__picker-empty {
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
/* 底部按键统一规格(同 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;
|
||||
background-color: #2b8fff;
|
||||
}
|
||||
|
||||
/* 提交中:纯色 + 透明度置灰(色相不脱节) */
|
||||
&--muted {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-text {
|
||||
font-size: 32rpx;
|
||||
color: #5c626b;
|
||||
|
||||
&--primary {
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
&__safe {
|
||||
height: calc(32rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user