待测试
This commit is contained in:
@@ -79,7 +79,7 @@ import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const editorState = ref("form");
|
||||
@@ -135,7 +135,7 @@ const uploadCover = async () => {
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -78,7 +78,7 @@ const uploadCover = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try { const receipt = await pickAndUploadImage({ requestController: controller }); coverOssId.value = toConsumerOssId(receipt.ossId); coverFileName.value = receipt.fileName || "封面图片"; }
|
||||
try { const receipt = await pickAndUploadImage({ requestController: controller }); coverOssId.value = receipt.ossId; coverFileName.value = receipt.fileName || "封面图片"; }
|
||||
catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; }
|
||||
finally { uploading.value = false; }
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
@@ -135,7 +135,7 @@ const submitPhoto = async () => {
|
||||
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: toConsumerOssId(receipt.value.ossId),
|
||||
ossId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
|
||||
@@ -137,7 +137,7 @@ import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { finishPage, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const form = reactive({
|
||||
@@ -307,7 +307,7 @@ const uploadCover = async () => {
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: createController });
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
|
||||
@@ -113,7 +113,7 @@ import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -284,7 +284,7 @@ const uploadCover = async () => {
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
|
||||
@@ -1,11 +1,76 @@
|
||||
<!-- 页面编号:N-02;用途:通知详情。没有详情读取 operation 时保持关闭。 -->
|
||||
<!-- 页面编号:N-02;用途:按通知 ID 读取服务端通知详情。 -->
|
||||
<template>
|
||||
<view class="message-detail-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view><view class="page-content"><view class="state-card"><text>暂无法查看消息详情</text><text>请返回消息中心查看已收到的消息。</text><AppButton block label="返回消息中心" @click="backToMessages" /></view></view></view>
|
||||
<view class="message-detail-page">
|
||||
<ModulePageBackground module="notification" />
|
||||
<view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取消息详情" /></view>
|
||||
<view v-else-if="state === 'error'" class="state-card">
|
||||
<text>暂时无法读取消息详情</text>
|
||||
<text>{{ loadError || "请稍后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadDetail" />
|
||||
<AppButton block label="返回消息中心" @click="backToMessages" />
|
||||
</view>
|
||||
<view v-else class="detail-card">
|
||||
<text class="detail-title">{{ detail.noticeTitle || "通知消息" }}</text>
|
||||
<text v-if="detail.publishTime" class="detail-time">{{ detail.publishTime }}</text>
|
||||
<view class="detail-content"><text>{{ detail.noticeContent || "暂无通知正文" }}</text></view>
|
||||
<view v-if="detail.noticeType || detail.genealogyName || detail.senderNickName || detail.bizSummary" class="detail-meta">
|
||||
<view v-if="detail.noticeType" class="detail-meta__row"><text>通知类型</text><text>{{ detail.noticeType }}</text></view>
|
||||
<view v-if="detail.genealogyName" class="detail-meta__row"><text>所属家谱</text><text>{{ detail.genealogyName }}</text></view>
|
||||
<view v-if="detail.senderNickName" class="detail-meta__row"><text>发送人</text><text>{{ detail.senderNickName }}</text></view>
|
||||
<view v-if="detail.bizSummary" class="detail-meta__row"><text>关联事项</text><text>{{ detail.bizSummary }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToMessages=()=>returnTo("N01");
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const notificationId = ref("");
|
||||
const detail = ref({});
|
||||
const state = ref("loading");
|
||||
const loadError = ref("");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
|
||||
const loadDetail = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
loadError.value = "";
|
||||
try {
|
||||
detail.value = await appApi.getNotificationDetail(notificationId.value, { requestController: controller });
|
||||
if (!active) return;
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
loadError.value = error?.message || "请稍后重试。";
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const backToMessages = () => returnTo("N01");
|
||||
|
||||
onLoad((options) => {
|
||||
notificationId.value = options?.id || "";
|
||||
loadDetail();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.message-detail-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
|
||||
.message-detail-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{flex:1;padding:18rpx 24rpx 72rpx}.state-card,.detail-card{box-sizing:border-box;@include adaptive.adaptive-family-content}.state-card{min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center}.state-card text{display:block}.state-card text:first-child,.detail-title{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}.detail-card{padding:44rpx 38rpx}.detail-title,.detail-time{display:block}.detail-time{margin-top:16rpx;color:$ink-muted;font-size:24rpx}.detail-content{margin-top:36rpx;color:$ink;font-size:29rpx;line-height:1.8;white-space:pre-wrap}.detail-meta{margin-top:42rpx;padding-top:24rpx;border-top:1rpx solid rgba(91,70,42,.16)}.detail-meta__row{display:flex;gap:22rpx;padding:10rpx 0;color:$ink-muted;font-size:24rpx;line-height:1.5}.detail-meta__row text:first-child{flex:0 0 116rpx}.detail-meta__row text:last-child{flex:1;color:$ink}
|
||||
</style>
|
||||
|
||||
@@ -131,7 +131,7 @@ const buildUpdatePayload = () => {
|
||||
for (const field of ["nickName", "realName", "sex", "email"]) {
|
||||
if (form[field] && form[field] !== original[field]) data[field] = form[field];
|
||||
}
|
||||
if (form.birthday && form.birthday !== original.birthday) data.birthday = `${form.birthday}T00:00:00`;
|
||||
if (form.birthday && form.birthday !== original.birthday) data.birthday = form.birthday;
|
||||
if (avatarId.value && avatarId.value !== original.avatar) data.avatar = avatarId.value;
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
<text class="field-row__label">地点</text>
|
||||
<input v-model="form.location" maxlength="60" placeholder="请输入地点" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">详细地址</text>
|
||||
<input v-model="form.locationAddress" maxlength="120" placeholder="请输入详细地址" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">经度</text>
|
||||
<input v-model="form.longitude" type="digit" placeholder="地图选点后填写" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">纬度</text>
|
||||
<input v-model="form.latitude" type="digit" placeholder="地图选点后填写" placeholder-class="placeholder" @input="clearError" />
|
||||
</view>
|
||||
<view class="cover-field">
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
@@ -66,13 +78,13 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyDate: "", ceremonyClock: "", location: "", sortOrder: "" });
|
||||
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyDate: "", ceremonyClock: "", location: "", locationAddress: "", longitude: "", latitude: "", sortOrder: "" });
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const uploadError = ref("");
|
||||
@@ -103,7 +115,7 @@ const uploadCover = async () => {
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "活动封面";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
@@ -121,7 +133,13 @@ const submit = async () => {
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
|
||||
await appApi.createCeremony(genealogyId.value, { ...ceremonyForm, ceremonyTime: ceremonyTime.value, ...(coverOssId.value ? { coverOssId: coverOssId.value } : {}) }, { requestController: controller });
|
||||
await appApi.createCeremony(genealogyId.value, {
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(form.longitude ? { longitude: Number(form.longitude) } : {}),
|
||||
...(form.latitude ? { latitude: Number(form.latitude) } : {}),
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<text>新建功德记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view>
|
||||
<view class="field field--picker"><text>功德类型</text><picker :range="meritTypeOptions.map((item) => item.label)" :value="meritTypeIndex" @change="selectMeritType"><view :class="{ placeholder: !form.type }">{{ meritTypeLabel || '请选择' }}</view></picker></view>
|
||||
<view class="field field--picker"><text>记录日期</text><picker mode="date" :value="form.meritDate" @change="selectMeritDate"><view :class="{ placeholder: !form.meritDate }">{{ form.meritDate || '请选择' }}</view></picker></view>
|
||||
<view class="field field--picker"><text>记录时间</text><picker mode="time" :value="form.meritClock" @change="selectMeritClock"><view :class="{ placeholder: !form.meritClock }">{{ form.meritClock || '请选择' }}</view></picker></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
@@ -51,16 +52,23 @@ const form = reactive({ donor: "", title: "", type: "", amount: "", meritDate: "
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "type", label: "功德类型" },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
{ key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" },
|
||||
];
|
||||
const meritTypeOptions = Object.freeze([
|
||||
{ value: "donation", label: "捐赠" },
|
||||
{ value: "repair", label: "修祠" },
|
||||
{ value: "public", label: "公益" },
|
||||
{ value: "other", label: "其他" },
|
||||
]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
|
||||
const meritTime = computed(() => form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "");
|
||||
const meritTypeIndex = computed(() => Math.max(0, meritTypeOptions.findIndex((item) => item.value === form.type)));
|
||||
const meritTypeLabel = computed(() => meritTypeOptions.find((item) => item.value === form.type)?.label || "");
|
||||
const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2));
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
||||
@@ -93,6 +101,7 @@ const openCreate = () => { if (!valid.value) return; saveNotice.value = false; r
|
||||
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
||||
const selectMeritDate = (event) => { form.meritDate = event.detail.value || ""; error.value = ""; };
|
||||
const selectMeritClock = (event) => { form.meritClock = event.detail.value || ""; error.value = ""; };
|
||||
const selectMeritType = (event) => { form.type = meritTypeOptions[Number(event.detail.value)]?.value || ""; error.value = ""; };
|
||||
const submit = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
v-for="connector in lineageConnectors"
|
||||
:key="connector.id"
|
||||
class="lineage-connector"
|
||||
:class="{ 'lineage-connector--spouse': connector.kind === 'spouse' }"
|
||||
:style="connector.style"
|
||||
/>
|
||||
|
||||
@@ -97,30 +98,33 @@
|
||||
:key="member.id"
|
||||
:id="`tree-member-${member.id}`"
|
||||
class="member-node"
|
||||
:class="{ 'member-node--selected': selected?.id === member.id }"
|
||||
:class="{
|
||||
'member-node--selected': selected?.id === member.id,
|
||||
'member-node--spouse': Boolean(member.spouseOf),
|
||||
}"
|
||||
:style="nodeGridStyle(member)"
|
||||
@click="openMemberPanel(member)"
|
||||
>
|
||||
<image
|
||||
class="member-node__skin"
|
||||
:src="
|
||||
selected?.id === member.id
|
||||
? '/static/assets/modules/tree/transparent/t01-member-node-selected.png'
|
||||
: '/static/assets/modules/tree/transparent/t01-member-node-standard.png'
|
||||
"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<image
|
||||
class="member-node__avatar"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation"
|
||||
>{{ member.relation }} · {{ member.branch }}</text
|
||||
>
|
||||
<text class="node-years">{{ member.years }}</text>
|
||||
<view class="member-node__surface">
|
||||
<view class="member-node__portrait">
|
||||
<image
|
||||
class="member-node__avatar"
|
||||
src="/static/assets/foundation/transparent/tab-profile.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<image
|
||||
class="member-node__divider"
|
||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation"
|
||||
>{{ nodeRelationText(member) }}</text
|
||||
>
|
||||
<text class="node-years">{{ member.years }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -168,7 +172,7 @@
|
||||
>
|
||||
<image
|
||||
class="member-action-profile__avatar"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
src="/static/assets/foundation/transparent/tab-profile.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-action-profile__copy">
|
||||
@@ -202,7 +206,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
@@ -234,9 +238,11 @@ let treeLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const GRID_UNIT = 5;
|
||||
const NODE_HALF_HEIGHT = 47;
|
||||
const MEMBER_GAP = 250;
|
||||
const GENERATION_GAP = 220;
|
||||
const NODE_HALF_WIDTH = 80;
|
||||
const NODE_HALF_HEIGHT = 94;
|
||||
const FAMILY_LINK_OFFSET = 18;
|
||||
const MEMBER_GAP = 178;
|
||||
const GENERATION_GAP = 260;
|
||||
const members = ref([]);
|
||||
|
||||
const memberActions = Object.freeze([
|
||||
@@ -257,6 +263,10 @@ const memberActions = Object.freeze([
|
||||
]);
|
||||
|
||||
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
|
||||
const nodeRelationText = (member) => {
|
||||
const branch = String(member.branch || "").replace(/字辈$/, "");
|
||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
||||
};
|
||||
const layoutMembers = computed(() => {
|
||||
const generations = Array.from(
|
||||
new Set(members.value.map((member) => Number(member.generation))),
|
||||
@@ -269,12 +279,14 @@ const layoutMembers = computed(() => {
|
||||
.sort(
|
||||
(left, right) =>
|
||||
String(left.parentId || "").localeCompare(String(right.parentId || "")) ||
|
||||
String(left.spouseOf || left.id).localeCompare(String(right.spouseOf || right.id)) ||
|
||||
Number(Boolean(left.spouseOf)) - Number(Boolean(right.spouseOf)) ||
|
||||
String(left.id).localeCompare(String(right.id)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const maxCount = Math.max(1, ...Array.from(groups.values()).map((group) => group.length));
|
||||
const canvasWidth = Math.max(720, maxCount * MEMBER_GAP + 100);
|
||||
const canvasWidth = Math.max(660, maxCount * MEMBER_GAP + 80);
|
||||
return generations.flatMap((generation, generationIndex) => {
|
||||
const group = groups.get(generation) || [];
|
||||
const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP);
|
||||
@@ -282,7 +294,7 @@ const layoutMembers = computed(() => {
|
||||
return group.map((member, memberIndex) => ({
|
||||
...member,
|
||||
x: snapToGrid(startX + memberIndex * MEMBER_GAP),
|
||||
y: snapToGrid(135 + generationIndex * GENERATION_GAP),
|
||||
y: snapToGrid(200 + generationIndex * GENERATION_GAP),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -290,7 +302,7 @@ const treeMetrics = computed(() => {
|
||||
const memberList = layoutMembers.value;
|
||||
const maxX = Math.max(0, ...memberList.map((member) => member.x));
|
||||
const maxY = Math.max(0, ...memberList.map((member) => member.y));
|
||||
const width = Math.max(720, snapToGrid(maxX + 210));
|
||||
const width = Math.max(660, snapToGrid(maxX + NODE_HALF_WIDTH + 50));
|
||||
const height = Math.max(640, snapToGrid(maxY + 250));
|
||||
return {
|
||||
width,
|
||||
@@ -345,9 +357,15 @@ const lineageConnectors = computed(() => {
|
||||
const memberById = new Map(
|
||||
layoutMembers.value.map((member) => [member.id, member]),
|
||||
);
|
||||
const spouseByPersonId = new Map();
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (member.spouseOf && memberById.has(member.spouseOf)) {
|
||||
spouseByPersonId.set(member.spouseOf, member);
|
||||
}
|
||||
});
|
||||
const childrenByParent = new Map();
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (!member.parentId || !memberById.has(member.parentId)) return;
|
||||
if (member.spouseOf || !member.parentId || !memberById.has(member.parentId)) return;
|
||||
const children = childrenByParent.get(member.parentId) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(member.parentId, children);
|
||||
@@ -356,18 +374,46 @@ const lineageConnectors = computed(() => {
|
||||
const connectors = [];
|
||||
childrenByParent.forEach((children, parentId) => {
|
||||
const parent = memberById.get(parentId);
|
||||
const childTop = Math.min(...children.map((child) => child.y - NODE_HALF_HEIGHT));
|
||||
const spouse = spouseByPersonId.get(parentId);
|
||||
const parentAnchorX = spouse
|
||||
? snapToGrid((parent.x + spouse.x) / 2)
|
||||
: parent.x;
|
||||
const parentBottom = parent.y + NODE_HALF_HEIGHT;
|
||||
const branchY = snapToGrid((parentBottom + childTop) / 2);
|
||||
const parentAnchorY = spouse
|
||||
? snapToGrid(parentBottom + FAMILY_LINK_OFFSET)
|
||||
: parentBottom;
|
||||
const childTop = Math.min(...children.map((child) => child.y - NODE_HALF_HEIGHT));
|
||||
const branchY = snapToGrid((parentAnchorY + childTop) / 2);
|
||||
const minX = Math.min(...children.map((child) => child.x));
|
||||
const maxX = Math.max(...children.map((child) => child.x));
|
||||
const verticalStyle = (x, top, bottom) => ({
|
||||
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
|
||||
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
|
||||
});
|
||||
const horizontalStyle = (left, right, y) => ({
|
||||
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
|
||||
gridRow: `${Math.round(y / GRID_UNIT) + 1} / span 1`,
|
||||
});
|
||||
if (spouse) {
|
||||
connectors.push({
|
||||
id: `${parentId}-${spouse.id}-family-parent`,
|
||||
kind: "family",
|
||||
style: verticalStyle(parent.x, parentBottom, parentAnchorY),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-${spouse.id}-family-spouse`,
|
||||
kind: "family",
|
||||
style: verticalStyle(spouse.x, spouse.y + NODE_HALF_HEIGHT, parentAnchorY),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-${spouse.id}-family-bridge`,
|
||||
kind: "family",
|
||||
style: horizontalStyle(Math.min(parent.x, spouse.x), Math.max(parent.x, spouse.x), parentAnchorY),
|
||||
});
|
||||
}
|
||||
connectors.push({
|
||||
id: `${parentId}-trunk`,
|
||||
style: verticalStyle(parent.x, parentBottom, branchY),
|
||||
style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-branch`,
|
||||
@@ -381,6 +427,23 @@ const lineageConnectors = computed(() => {
|
||||
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
|
||||
}));
|
||||
});
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (!member.spouseOf) return;
|
||||
const partner = memberById.get(member.spouseOf);
|
||||
if (!partner || partner.generation !== member.generation) return;
|
||||
if (childrenByParent.has(partner.id)) return;
|
||||
const left = Math.min(member.x, partner.x) + NODE_HALF_WIDTH;
|
||||
const right = Math.max(member.x, partner.x) - NODE_HALF_WIDTH;
|
||||
if (right <= left) return;
|
||||
connectors.push({
|
||||
id: `${partner.id}-${member.id}-spouse`,
|
||||
kind: "spouse",
|
||||
style: {
|
||||
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
|
||||
gridRow: `${Math.round(member.y / GRID_UNIT) + 1} / span 1`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return connectors;
|
||||
});
|
||||
|
||||
@@ -444,6 +507,10 @@ const loadTree = async (query = {}) => {
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || layoutMembers.value[0] || null;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
if (treeState.value === "tree" && selected.value) {
|
||||
await nextTick();
|
||||
recenterSelectedMember();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
@@ -481,22 +548,46 @@ const handleTreeScroll = (event) => {
|
||||
treeHasDrifted.value = Math.abs(scrollLeft - centeredTreeScrollLeft.value) > 80;
|
||||
};
|
||||
|
||||
const getFamilyPartner = (member) => {
|
||||
if (!member) return null;
|
||||
const partnerId = member.spouseOf || layoutMembers.value.find(
|
||||
(item) => String(item.spouseOf || "") === String(member.id),
|
||||
)?.id;
|
||||
const partner = layoutMembers.value.find(
|
||||
(item) => String(item.id) === String(partnerId || ""),
|
||||
);
|
||||
return partner && partner.generation === member.generation ? partner : null;
|
||||
};
|
||||
|
||||
const recenterSelectedMember = async () => {
|
||||
if (!selected.value) return;
|
||||
const spouse = getFamilyPartner(selected.value);
|
||||
ignoreTreeScroll = true;
|
||||
const [scrollRect, nodeRect] = await new Promise((resolve) => {
|
||||
const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(".tree-scroll").boundingClientRect();
|
||||
query
|
||||
.select(`#tree-member-${selected.value.id}`)
|
||||
.boundingClientRect();
|
||||
if (spouse) {
|
||||
query.select(`#tree-member-${spouse.id}`).boundingClientRect();
|
||||
}
|
||||
query.exec(resolve);
|
||||
});
|
||||
if (scrollRect && nodeRect) {
|
||||
const targetLeft = spouseRect
|
||||
? Math.min(nodeRect.left, spouseRect.left)
|
||||
: nodeRect.left;
|
||||
const targetRight = spouseRect
|
||||
? Math.max(
|
||||
nodeRect.left + nodeRect.width,
|
||||
spouseRect.left + spouseRect.width,
|
||||
)
|
||||
: nodeRect.left + nodeRect.width;
|
||||
const centeredScrollLeft = Math.max(
|
||||
0,
|
||||
currentTreeScrollLeft.value +
|
||||
nodeRect.left + nodeRect.width / 2 - (scrollRect.left + scrollRect.width / 2),
|
||||
(targetLeft + targetRight) / 2 - (scrollRect.left + scrollRect.width / 2),
|
||||
);
|
||||
treeScrollLeft.value = centeredScrollLeft;
|
||||
currentTreeScrollLeft.value = centeredScrollLeft;
|
||||
@@ -613,7 +704,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.tree-stage--lineage {
|
||||
display: grid;
|
||||
grid-template-columns: 190rpx minmax(0, 1fr);
|
||||
grid-template-columns: 144rpx minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.tree-scroll {
|
||||
@@ -624,7 +715,7 @@ const openMemberAction = (action) => {
|
||||
.generation-rail {
|
||||
display: grid;
|
||||
z-index: 4;
|
||||
width: 190rpx;
|
||||
width: 144rpx;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
||||
@@ -635,7 +726,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.tree-canvas {
|
||||
display: grid;
|
||||
margin: 0 16rpx;
|
||||
margin: 0 12rpx;
|
||||
}
|
||||
.tree-canvas--state {
|
||||
display: block;
|
||||
@@ -655,7 +746,7 @@ const openMemberAction = (action) => {
|
||||
display: grid;
|
||||
justify-self: center;
|
||||
z-index: 3;
|
||||
width: 166rpx;
|
||||
width: 126rpx;
|
||||
height: 58rpx;
|
||||
color: #8f6c3f;
|
||||
font-size: 22rpx;
|
||||
@@ -679,7 +770,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.generation-band__copy text:last-child {
|
||||
margin-top: 1rpx;
|
||||
font-size: 19rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
.lineage-connector {
|
||||
align-self: stretch;
|
||||
@@ -687,36 +778,57 @@ const openMemberAction = (action) => {
|
||||
z-index: 1;
|
||||
background: #b78a42;
|
||||
}
|
||||
.lineage-connector--spouse {
|
||||
background: rgba(183, 117, 76, 0.82);
|
||||
}
|
||||
.member-node {
|
||||
display: grid;
|
||||
position: relative;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
z-index: 2;
|
||||
width: 224rpx;
|
||||
height: 86rpx;
|
||||
width: 160rpx;
|
||||
height: 188rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.member-node__skin {
|
||||
.member-node__surface {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 9rpx 8rpx 8rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 252, 244, 0.96);
|
||||
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
|
||||
}
|
||||
.member-node__skin,
|
||||
.member-node__copy {
|
||||
grid-area: 1 / 1;
|
||||
.member-node__portrait {
|
||||
display: flex;
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid #c49a57;
|
||||
border-radius: 50%;
|
||||
background: #fffdf7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-node__avatar {
|
||||
grid-area: 1 / 1;
|
||||
z-index: 3;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
margin: 13rpx 0 0 18rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.member-node__divider {
|
||||
width: 56rpx;
|
||||
height: 14rpx;
|
||||
margin-top: 3rpx;
|
||||
}
|
||||
.member-node__copy {
|
||||
z-index: 1;
|
||||
padding-left: 44rpx;
|
||||
width: 100%;
|
||||
margin-top: 1rpx;
|
||||
padding: 0;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
@@ -724,25 +836,48 @@ const openMemberAction = (action) => {
|
||||
display: block;
|
||||
}
|
||||
.node-name {
|
||||
padding-top: 5rpx;
|
||||
color: $ink;
|
||||
display: -webkit-box;
|
||||
color: #6b2419;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 25rpx;
|
||||
overflow: hidden;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.node-relation {
|
||||
margin-top: 0;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #644b2e;
|
||||
font-size: 18rpx;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-years {
|
||||
margin-top: 0;
|
||||
color: $ink-muted;
|
||||
font-size: 20rpx;
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #695b4c;
|
||||
font-size: 17rpx;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-node--spouse .member-node__surface {
|
||||
border-color: #c39a61;
|
||||
background: rgba(255, 250, 241, 0.98);
|
||||
}
|
||||
.member-node--selected {
|
||||
width: 236rpx;
|
||||
height: 94rpx;
|
||||
z-index: 3;
|
||||
}
|
||||
.member-node--selected .member-node__surface {
|
||||
border-color: #c55344;
|
||||
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
|
||||
}
|
||||
.member-node--selected .member-node__portrait {
|
||||
border-color: #c55344;
|
||||
}
|
||||
.member-node--selected .node-name {
|
||||
color: $brand-red;
|
||||
|
||||
+186
-16
@@ -37,6 +37,34 @@
|
||||
</view>
|
||||
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>人物编号</text>
|
||||
<input v-model="addForm.personNo" maxlength="40" placeholder="不填则由服务端生成" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="sexOptions.map((item) => item.label)" :value="optionIndex(sexOptions, addForm.sex)" @change="selectOption('sex', sexOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>性别</text><text>{{ optionLabel(sexOptions, addForm.sex) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker :range="bindingModeOptions.map((item) => item.label)" :value="optionIndex(bindingModeOptions, addForm.bindingMode)" @change="selectBindingMode">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>身份认领</text><text>{{ optionLabel(bindingModeOptions, addForm.bindingMode) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>业务用户</text>
|
||||
<text class="form-field__hint">{{ appUserBindingHint }}</text>
|
||||
</view>
|
||||
<text v-if="fieldErrors.bindingMode" class="field-error">{{ fieldErrors.bindingMode }}</text>
|
||||
<view class="form-field form-field--upload">
|
||||
<text>头像</text>
|
||||
<view>
|
||||
<button class="upload-button" :disabled="isAvatarUploading || isSubmitting" @click="uploadAvatar">{{ isAvatarUploading ? "上传中…" : "选择图片" }}</button>
|
||||
<text v-if="avatarFileName" class="upload-receipt">已上传:{{ avatarFileName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="avatarUploadError" class="field-error">{{ avatarUploadError }}</text>
|
||||
|
||||
<picker
|
||||
v-if="!isFirstMember"
|
||||
:range="relationOptions"
|
||||
@@ -58,16 +86,28 @@
|
||||
<text>字辈</text>
|
||||
<input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>世代</text>
|
||||
<input v-if="!isFirstMember" v-model="addForm.generation" type="number" placeholder="可选正整数" placeholder-class="form-placeholder" />
|
||||
<text v-else>首位成员固定为 1</text>
|
||||
</view>
|
||||
<picker :range="personOptionLabels" :value="personOptionIndex(addForm.fatherId)" @change="selectPerson('fatherId', $event)">
|
||||
<view class="form-field form-field--picker"><text>父亲</text><text>{{ personOptionLabel(addForm.fatherId) }}</text></view>
|
||||
</picker>
|
||||
<picker :range="personOptionLabels" :value="personOptionIndex(addForm.motherId)" @change="selectPerson('motherId', $event)">
|
||||
<view class="form-field form-field--picker"><text>母亲</text><text>{{ personOptionLabel(addForm.motherId) }}</text></view>
|
||||
</picker>
|
||||
|
||||
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生农历</text>
|
||||
<input v-model="addForm.birthLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, addForm.birthLunar)" @change="selectOption('birthLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生农历</text><text>{{ optionLabel(lunarOptions, addForm.birthLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生地</text>
|
||||
<input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -78,10 +118,11 @@
|
||||
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世农历</text>
|
||||
<input v-model="addForm.deathLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, addForm.deathLunar)" @change="selectOption('deathLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世农历</text><text>{{ optionLabel(lunarOptions, addForm.deathLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -90,6 +131,20 @@
|
||||
<text>安葬地</text>
|
||||
<input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="personStatusOptions.map((item) => item.label)" :value="optionIndex(personStatusOptions, addForm.personStatus)" @change="selectOption('personStatus', personStatusOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>人物状态</text><text>{{ optionLabel(personStatusOptions, addForm.personStatus) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>排序值</text>
|
||||
<input v-model="addForm.sortOrder" type="number" placeholder="可选整数,值越小越靠前" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>关系名称</text>
|
||||
<input v-if="isFirstMember" v-model="addForm.relationName" maxlength="30" placeholder="可选关系显示名称" placeholder-class="form-placeholder" />
|
||||
<text v-else>{{ relationLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
@@ -153,6 +208,7 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
handleBackPress,
|
||||
@@ -166,10 +222,14 @@ const personId = ref("");
|
||||
const mode = ref("relative");
|
||||
const relationType = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const addRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const relationIntents = Object.freeze({
|
||||
@@ -181,11 +241,39 @@ const relationIntents = Object.freeze({
|
||||
DAUGHTER: { label: "女儿" },
|
||||
});
|
||||
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
|
||||
const sexOptions = Object.freeze([
|
||||
{ label: "男", value: "0" },
|
||||
{ label: "女", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const lunarOptions = Object.freeze([
|
||||
{ label: "否", value: "0" },
|
||||
{ label: "是", value: "1" },
|
||||
]);
|
||||
const personStatusOptions = Object.freeze([
|
||||
{ label: "健在", value: "0" },
|
||||
{ label: "已故", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
{ label: "绑定指定用户", value: "SPECIFIED" },
|
||||
]);
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const personOptionLabels = computed(() => personOptions.value.map((item) => item.label));
|
||||
const addForm = reactive({
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
@@ -193,10 +281,13 @@ const addForm = reactive({
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "" });
|
||||
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
|
||||
@@ -205,6 +296,11 @@ const relationOptions = computed(() => {
|
||||
return types.map((type) => relationIntents[type].label);
|
||||
});
|
||||
const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系");
|
||||
const appUserBindingHint = computed(() => {
|
||||
if (addForm.bindingMode === "NONE") return "不绑定账号,不提交业务用户 ID";
|
||||
if (addForm.bindingMode === "SELF") return "由服务端从 Token 绑定当前登录账号,不提交业务用户 ID";
|
||||
return "需先选择指定业务用户;当前没有可用候选接口";
|
||||
});
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
Boolean(genealogyId.value) &&
|
||||
@@ -239,7 +335,7 @@ const resultCopy = computed(() => {
|
||||
};
|
||||
});
|
||||
const hasDraft = computed(() =>
|
||||
Object.values(addForm).some((value) => String(value).trim()),
|
||||
Object.entries(addForm).some(([field, value]) => field !== "bindingMode" && String(value).trim()),
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardDialogVisible.value = visible;
|
||||
@@ -265,7 +361,22 @@ const loadCurrentMember = async () => {
|
||||
errorMessage.value = error?.message || "当前成员暂不可用。";
|
||||
}
|
||||
};
|
||||
|
||||
const loadPersonOptions = async () => {
|
||||
try {
|
||||
const rows = await appApi.getLineagePersonOptions(genealogyId.value, { requestController: personOptionsRequestController });
|
||||
const options = rows
|
||||
.map((item) => {
|
||||
const value = String(item?.personId ?? item?.id ?? "");
|
||||
if (!/^[1-9]\d*$/.test(value)) return null;
|
||||
const name = String(item?.name || item?.personName || item?.personNo || "未命名人物");
|
||||
return { value, label: `${name}(${value})` };
|
||||
})
|
||||
.filter(Boolean);
|
||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) personOptions.value = [{ label: "暂无可选人物", value: "" }];
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
@@ -284,6 +395,7 @@ onLoad((query) => {
|
||||
if (activeRelationIntent.value) {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
void loadPersonOptions();
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
@@ -293,6 +405,7 @@ onLoad((query) => {
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
addRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -300,7 +413,7 @@ const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
dirty: addState.value === "form" && hasDraft.value,
|
||||
submitting: isSubmitting.value,
|
||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -313,21 +426,58 @@ const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
clearError("relation");
|
||||
};
|
||||
const optionIndex = (options, value) => Math.max(0, options.findIndex((item) => item.value === value));
|
||||
const optionLabel = (options, value) => options.find((item) => item.value === value)?.label || "";
|
||||
const selectOption = (field, options, event) => {
|
||||
addForm[field] = options[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
addForm.bindingMode = bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const personOptionIndex = (value) => Math.max(0, personOptions.value.findIndex((item) => item.value === value));
|
||||
const personOptionLabel = (value) => personOptions.value.find((item) => item.value === value)?.label || "不选择";
|
||||
const selectPerson = (field, event) => {
|
||||
addForm[field] = personOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const uploadAvatar = async () => {
|
||||
if (isAvatarUploading.value || isSubmitting.value) return;
|
||||
isAvatarUploading.value = true;
|
||||
avatarUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: addRequestController });
|
||||
addForm.avatarOssId = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "头像图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
} finally {
|
||||
isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
|
||||
const selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; };
|
||||
const validateAddForm = () => {
|
||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
|
||||
return !fieldErrors.name && !fieldErrors.relation;
|
||||
fieldErrors.bindingMode = addForm.bindingMode === "SPECIFIED"
|
||||
? "当前未提供可选业务用户,不能指定认领"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
|
||||
};
|
||||
const submitAdd = async () => {
|
||||
if (isSubmitting.value || !validateAddForm()) return;
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm()) return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
personNo: addForm.personNo,
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
fatherId: addForm.fatherId,
|
||||
motherId: addForm.motherId,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
@@ -335,10 +485,16 @@ const submitAdd = async () => {
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
...(isFirstMember.value ? { generation: 1 } : {}),
|
||||
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1, relationName: addForm.relationName }
|
||||
: {
|
||||
...(addForm.generation ? { generation: Number(addForm.generation) } : {}),
|
||||
relationName: relationLabel.value,
|
||||
}),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController });
|
||||
@@ -349,10 +505,17 @@ const submitAdd = async () => {
|
||||
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
|
||||
}
|
||||
Object.assign(addForm, {
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
@@ -360,9 +523,13 @@ const submitAdd = async () => {
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
avatarFileName.value = "";
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
@@ -396,6 +563,9 @@ const returnToTree = async () => {
|
||||
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
|
||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 22rpx; line-height: 1; transform: translateY(-2rpx); }
|
||||
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
|
||||
.form-field__hint, .upload-receipt { color: $ink-muted; font-size: 21rpx; line-height: 1.4; text-align: right; }
|
||||
.form-field--upload > view { display: grid; justify-items: end; gap: 6rpx; }
|
||||
.upload-button { min-height: 54rpx; margin: 0; padding: 0 16rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.form-field--summary { align-items: start; }
|
||||
.form-placeholder { color: #a79884; }
|
||||
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
|
||||
|
||||
+184
-14
@@ -27,6 +27,34 @@
|
||||
</view>
|
||||
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>人物编号</text>
|
||||
<input v-model="editForm.personNo" maxlength="40" placeholder="不填则由服务端生成" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="sexOptions.map((item) => item.label)" :value="optionIndex(sexOptions, editForm.sex)" @change="selectOption('sex', sexOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>性别</text><text>{{ optionLabel(sexOptions, editForm.sex) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker :range="bindingModeOptions.map((item) => item.label)" :value="optionIndex(bindingModeOptions, editForm.bindingMode)" @change="selectBindingMode">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>身份认领</text><text>{{ optionLabel(bindingModeOptions, editForm.bindingMode) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>业务用户</text>
|
||||
<text class="form-field__hint">{{ appUserBindingHint }}</text>
|
||||
</view>
|
||||
<text v-if="fieldErrors.bindingMode" class="field-error">{{ fieldErrors.bindingMode }}</text>
|
||||
<view class="form-field form-field--upload">
|
||||
<text>头像</text>
|
||||
<view>
|
||||
<button class="upload-button" :disabled="isAvatarUploading || isSubmitting" @click="uploadAvatar">{{ isAvatarUploading ? "上传中…" : "选择图片" }}</button>
|
||||
<text v-if="avatarFileName" class="upload-receipt">已上传:{{ avatarFileName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="avatarUploadError" class="field-error">{{ avatarUploadError }}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>别名</text>
|
||||
<input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" />
|
||||
@@ -35,13 +63,24 @@
|
||||
<text>字辈</text>
|
||||
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>世代</text>
|
||||
<input v-model="editForm.generation" type="number" placeholder="正整数" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="personOptionLabels" :value="personOptionIndex(editForm.fatherId)" @change="selectPerson('fatherId', $event)">
|
||||
<view class="form-field form-field--picker"><text>父亲</text><text>{{ personOptionLabel(editForm.fatherId) }}</text></view>
|
||||
</picker>
|
||||
<picker :range="personOptionLabels" :value="personOptionIndex(editForm.motherId)" @change="selectPerson('motherId', $event)">
|
||||
<view class="form-field form-field--picker"><text>母亲</text><text>{{ personOptionLabel(editForm.motherId) }}</text></view>
|
||||
</picker>
|
||||
<picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)">
|
||||
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生农历</text>
|
||||
<input v-model="editForm.birthLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, editForm.birthLunar)" @change="selectOption('birthLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生农历</text><text>{{ optionLabel(lunarOptions, editForm.birthLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生地</text>
|
||||
<input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -49,10 +88,11 @@
|
||||
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
|
||||
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世农历</text>
|
||||
<input v-model="editForm.deathLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, editForm.deathLunar)" @change="selectOption('deathLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世农历</text><text>{{ optionLabel(lunarOptions, editForm.deathLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -61,6 +101,19 @@
|
||||
<text>安葬地</text>
|
||||
<input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker :range="personStatusOptions.map((item) => item.label)" :value="optionIndex(personStatusOptions, editForm.personStatus)" @change="selectOption('personStatus', personStatusOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>人物状态</text><text>{{ optionLabel(personStatusOptions, editForm.personStatus) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>排序值</text>
|
||||
<input v-model="editForm.sortOrder" type="number" placeholder="可选整数,值越小越靠前" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>关系名称</text>
|
||||
<input v-model="editForm.relationName" maxlength="30" placeholder="可选关系显示名称" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
<textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" />
|
||||
@@ -112,6 +165,7 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
@@ -123,18 +177,30 @@ const editState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const failedAction = ref("load");
|
||||
const editRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const originalMember = ref(null);
|
||||
const baseline = ref("");
|
||||
const editForm = reactive({
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
@@ -142,10 +208,39 @@ const editForm = reactive({
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
summary: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
|
||||
const sexOptions = Object.freeze([
|
||||
{ label: "男", value: "0" },
|
||||
{ label: "女", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const lunarOptions = Object.freeze([
|
||||
{ label: "否", value: "0" },
|
||||
{ label: "是", value: "1" },
|
||||
]);
|
||||
const personStatusOptions = Object.freeze([
|
||||
{ label: "健在", value: "0" },
|
||||
{ label: "已故", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
{ label: "绑定指定用户", value: "SPECIFIED" },
|
||||
]);
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const personOptionLabels = computed(() => personOptions.value.map((item) => item.label));
|
||||
const appUserBindingHint = computed(() => {
|
||||
if (editForm.bindingMode === "NONE") return "不绑定账号,保存时不提交业务用户 ID";
|
||||
if (editForm.bindingMode === "SELF") return "由服务端从 Token 绑定当前登录账号,保存时不提交业务用户 ID";
|
||||
return editForm.appUserId ? "保留当前已认领的指定业务用户" : "需先选择指定业务用户;当前没有可用候选接口";
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "" });
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
const hasValidContext = computed(() =>
|
||||
@@ -170,6 +265,7 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const datePart = (value) => String(value || "").slice(0, 10);
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
@@ -181,18 +277,29 @@ const loadMember = async () => {
|
||||
if (activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
Object.assign(editForm, {
|
||||
appUserId: member.appUserId || "",
|
||||
bindingMode: member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
|
||||
personNo: member.personNo || "",
|
||||
name: member.name,
|
||||
aliasName: member.aliasName || "",
|
||||
sex: member.sex || "",
|
||||
generation: member.generation || "",
|
||||
generationName: member.generationName || "",
|
||||
birthDate: member.birthDate || "",
|
||||
fatherId: member.relatives.find((item) => item.relation === "父亲")?.id || "",
|
||||
motherId: member.relatives.find((item) => item.relation === "母亲")?.id || "",
|
||||
avatarOssId: member.avatarOssId || "",
|
||||
birthDate: datePart(member.birthDate),
|
||||
birthLunar: member.birthLunar || "",
|
||||
birthPlace: member.birthplace || "",
|
||||
deathDate: member.deathDate || "",
|
||||
deathDate: datePart(member.deathDate),
|
||||
deathLunar: member.deathLunar || "",
|
||||
deathPlace: member.deathPlace || "",
|
||||
burialPlace: member.burialPlace || "",
|
||||
personStatus: member.personStatus || "",
|
||||
summary: member.biography || "",
|
||||
remark: member.remark || "",
|
||||
sortOrder: member.sortOrder ?? "",
|
||||
relationName: member.relationName || "",
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
errorMessage.value = "";
|
||||
@@ -206,6 +313,22 @@ const loadMember = async () => {
|
||||
editState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
try {
|
||||
const rows = await appApi.getLineagePersonOptions(genealogyId.value, { requestController: personOptionsRequestController });
|
||||
const options = rows
|
||||
.map((item) => {
|
||||
const value = String(item?.personId ?? item?.id ?? "");
|
||||
if (!/^[1-9]\d*$/.test(value) || value === personId.value) return null;
|
||||
const name = String(item?.name || item?.personName || item?.personNo || "未命名人物");
|
||||
return { value, label: `${name}(${value})` };
|
||||
})
|
||||
.filter(Boolean);
|
||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) personOptions.value = [{ label: "暂无可选人物", value: "" }];
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
@@ -216,10 +339,12 @@ onLoad((query) => {
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
void loadPersonOptions();
|
||||
});
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
editRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -227,7 +352,7 @@ const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -236,6 +361,34 @@ const requestBack = () =>
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
const clearError = (field) => { fieldErrors[field] = ""; };
|
||||
const optionIndex = (options, value) => Math.max(0, options.findIndex((item) => item.value === value));
|
||||
const optionLabel = (options, value) => options.find((item) => item.value === value)?.label || "";
|
||||
const selectOption = (field, options, event) => {
|
||||
editForm[field] = options[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
editForm.bindingMode = bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const personOptionIndex = (value) => Math.max(0, personOptions.value.findIndex((item) => item.value === value));
|
||||
const personOptionLabel = (value) => personOptions.value.find((item) => item.value === value)?.label || "不选择";
|
||||
const selectPerson = (field, event) => {
|
||||
editForm[field] = personOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const uploadAvatar = async () => {
|
||||
if (isAvatarUploading.value || isSubmitting.value) return;
|
||||
isAvatarUploading.value = true;
|
||||
avatarUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: editRequestController });
|
||||
editForm.avatarOssId = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "头像图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
} finally {
|
||||
isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectDate = (field, event) => {
|
||||
editForm[field] = event.detail.value || "";
|
||||
fieldErrors.dates = "";
|
||||
@@ -243,16 +396,27 @@ const selectDate = (field, event) => {
|
||||
const validateEditForm = () => {
|
||||
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
|
||||
fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : "";
|
||||
return !fieldErrors.name && !fieldErrors.dates;
|
||||
fieldErrors.bindingMode = editForm.bindingMode === "SPECIFIED" && !editForm.appUserId
|
||||
? "当前未提供可选业务用户,不能指定认领"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.dates && !fieldErrors.bindingMode;
|
||||
};
|
||||
const saveMember = async () => {
|
||||
if (isSubmitting.value || !validateEditForm()) return;
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateEditForm()) return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await appApi.updatePerson(genealogyId.value, personId.value, {
|
||||
bindingMode: editForm.bindingMode,
|
||||
...(editForm.bindingMode === "SPECIFIED" ? { appUserId: editForm.appUserId } : {}),
|
||||
personNo: editForm.personNo,
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation ? Number(editForm.generation) : undefined,
|
||||
generationName: editForm.generationName,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
@@ -260,8 +424,11 @@ const saveMember = async () => {
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
personStatus: editForm.personStatus,
|
||||
biography: editForm.summary,
|
||||
remark: editForm.remark,
|
||||
sortOrder: editForm.sortOrder,
|
||||
relationName: editForm.relationName,
|
||||
}, { requestController: editRequestController });
|
||||
baseline.value = formSnapshot.value;
|
||||
await goBack();
|
||||
@@ -307,6 +474,9 @@ const handleResultAction = () => {
|
||||
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
|
||||
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
|
||||
.form-field__hint, .upload-receipt { color: $ink-muted; font-size: 21rpx; line-height: 1.4; text-align: right; }
|
||||
.form-field--upload > view { display: grid; justify-items: end; gap: 6rpx; }
|
||||
.upload-button { min-height: 54rpx; margin: 0; padding: 0 16rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||
.form-field--summary { align-items: start; }
|
||||
.form-placeholder { color: #a79884; }
|
||||
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
|
||||
|
||||
Reference in New Issue
Block a user