Files
jiapuapp/pages/tree/add-relative.vue
T

923 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view
class="add-relative-page"
:class="{
'add-state--form': addState === 'form',
'add-state--loading': addState === 'loading',
'add-state--error': addState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="add-relative-page__header">
<PageHeader
:title="isFirstMember ? '录入首位成员' : '新增亲属'"
custom-back
@back="requestBack"
/>
</view>
<view class="add-relative-panel">
<AppLoading
v-if="addState === 'loading'"
text="正在读取成员资料"
description="请稍候,正在确认当前人物。"
/>
<view v-else-if="addState === 'form'" class="add-relative-form">
<text class="form-eyebrow">{{
isFirstMember ? "建立世系起点" : "补全家族关系"
}}</text>
<text class="form-title">{{ formTitle }}</text>
<text class="form-copy">{{ formCopy }}</text>
<view v-if="!isFirstMember" class="member-context">
<text>当前成员</text
><text
>{{ currentMember.name }} ·
{{ currentMember.generation }} </text
>
</view>
<view class="form-field">
<text><text class="required-mark">*</text>姓名</text>
<input
v-model="addForm.name"
maxlength="20"
placeholder="请输入真实姓名"
placeholder-class="form-placeholder"
@input="clearError('name')"
/>
</view>
<text v-if="fieldErrors.name" class="field-error">{{
fieldErrors.name
}}</text>
<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>
<text v-if="fieldErrors.bindingMode" class="field-error">{{
fieldErrors.bindingMode
}}</text>
<picker
v-if="addForm.bindingMode === 'SPECIFIED'"
:range="memberOptionLabels"
:value="memberOptionIndex(addForm.appUserId)"
:disabled="memberOptionsState !== 'ready' || !memberOptions.length"
@change="selectBoundMember"
>
<view class="form-field form-field--picker">
<text><text class="required-mark">*</text>指定成员</text>
<text>{{ memberOptionLabel(addForm.appUserId) }}</text>
</view>
</picker>
<text
v-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'loading'"
class="form-note"
>正在读取可绑定成员</text
>
<text
v-else-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'error'"
class="field-error"
>可绑定成员暂不可用请稍后重试</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"
:value="relationIndex"
@change="selectRelation"
>
<view class="form-field form-field--picker">
<text><text class="required-mark">*</text>与本人关系</text>
<text>{{ addForm.relation || "请选择亲属关系" }}</text>
</view>
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{
fieldErrors.relation
}}</text>
<view class="form-field">
<text>别名</text>
<input
v-model="addForm.aliasName"
maxlength="40"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<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
mode="date"
:value="addForm.birthDate"
@change="selectBirthDate"
>
<view class="form-field form-field--picker">
<text>出生日期</text
><text>{{ addForm.birthDate || "请选择" }}</text>
</view>
</picker>
<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"
/>
</view>
<picker
v-if="isDeceased"
mode="date"
:value="addForm.deathDate"
@change="selectDeathDate"
>
<view class="form-field form-field--picker">
<text>逝世日期</text
><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
</picker>
<picker
v-if="isDeceased"
: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 v-if="isDeceased" class="form-field">
<text>逝世地</text>
<input
v-model="addForm.deathPlace"
maxlength="60"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<view v-if="isDeceased" class="form-field">
<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 form-field--summary">
<text>人物简介</text>
<textarea
v-model="addForm.biography"
auto-height
maxlength="500"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field form-field--summary">
<text>备注</text>
<textarea
v-model="addForm.remark"
auto-height
maxlength="200"
placeholder="补充说明"
placeholder-class="form-placeholder"
/>
</view>
<text class="form-note">{{ formNote }}</text>
<view class="form-action" @click="submitAdd">
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="scaleToFill"
/>
<text>{{ isSubmitting ? "正在保存…" : "保存成员" }}</text>
</view>
</view>
<view v-else class="add-result">
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view class="form-action" @click="handleResultAction">
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="scaleToFill"
/>
<text>{{ resultCopy.action }}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardDialogVisible"
eyebrow="尚未保存"
title="要放弃本次填写吗"
message="返回后,本次新增成员的内容不会保留。"
cancel-text="继续填写"
confirm-text="放弃并返回"
show-cancel
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
import {
findMemberOptionIndex,
findMemberOptionLabel,
memberFormOptions,
memberRelationOptions,
memberRelationTypes,
updateMemberBindingMode,
updateMemberFormOption,
} from "@/utils/tree/member-form.js";
const addState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const relationType = ref("");
const isSubmitting = ref(false);
const memberCreationCommitted = ref(false);
const memberCreationOutcomeUnknown = ref(false);
const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false);
const currentMember = ref(null);
const errorMessage = ref("");
const memberDetailRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberCreationRequestController = createRequestController();
const memberCreationGuard = createNonIdempotentWriteGuard();
let pageActive = true;
let loadSequence = 0;
const relationIntents = Object.freeze(
Object.fromEntries(
memberRelationOptions.map(({ value, label }) => [value, Object.freeze({ label })]),
),
);
const genericRelationTypes = Object.freeze(
memberRelationOptions.map(({ value }) => value),
);
const sexOptions = memberFormOptions.sex;
const lunarOptions = memberFormOptions.lunar;
const personStatusOptions = memberFormOptions.personStatus;
const bindingModeOptions = memberFormOptions.bindingMode;
const addForm = reactive({
bindingMode: "NONE",
appUserId: "",
name: "",
relation: "",
aliasName: "",
sex: "",
generation: "",
generationName: "",
avatarOssId: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
personStatus: "",
biography: "",
remark: "",
sortOrder: "",
});
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
const memberOptionsState = ref("loading");
const memberOptions = ref([]);
const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
const activeRelationIntent = computed(
() => relationIntents[relationType.value] || null,
);
const relationOptions = computed(() => {
const types = activeRelationIntent.value
? [relationType.value]
: genericRelationTypes;
return types.map((type) => relationIntents[type].label);
});
const relationLabel = computed(
() => activeRelationIntent.value?.label || addForm.relation || "亲属关系",
);
const memberOptionLabels = computed(() =>
memberOptions.value.map((item) => item.label),
);
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() =>
Math.max(0, relationOptions.value.indexOf(addForm.relation)),
);
const formTitle = computed(() =>
isFirstMember.value
? "录入家谱中的第一位成员"
: `为${currentMember.value?.name || "当前成员"}添加${relationLabel.value}`,
);
const formCopy = computed(() =>
isFirstMember.value
? "首位成员将成为世系起点,后续可从此人继续补充配偶、子女和后代。"
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
const resultCopy = computed(() => {
if (memberCreationOutcomeUnknown.value) {
return {
eyebrow: "结果待确认",
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}可能已经保存`,
copy:
errorMessage.value ||
"暂时无法确认成员是否已经写入。请先返回世系树检查,不要重复新增。",
action: "返回世系树",
};
}
if (memberCreationCommitted.value) {
return {
eyebrow: "保存完成",
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}已经保存`,
copy:
errorMessage.value ||
"成员已经写入家谱,但页面暂时没有返回世系树。请重试返回,不要重复新增。",
action: "返回世系树",
};
}
if (hasValidContext.value) {
return {
eyebrow: "保存失败",
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}尚未保存`,
copy: errorMessage.value || "成员资料尚未保存,请检查填写后重试。",
action: "返回修改",
};
}
return {
eyebrow: "暂时无法打开成员页面",
title: "没有找到要关联的成员",
copy: errorMessage.value || "请从世系树重新进入。",
action: "返回世系树",
};
});
const hasDraft = computed(() =>
Object.entries(addForm).some(
([field, value]) =>
field !== "bindingMode" && field !== "appUserId" && String(value).trim(),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadCurrentMember = async () => {
const activeLoad = ++loadSequence;
addState.value = "loading";
try {
const member = await lineageApi.getPerson(genealogyId.value, personId.value, {
requestController: memberDetailRequestController,
});
if (!pageActive || activeLoad !== loadSequence) return;
currentMember.value = member;
addState.value = "form";
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
currentMember.value = null;
addState.value = "error";
errorMessage.value = getRequestErrorMessage(error, "当前成员暂不可用。");
}
};
const loadMemberOptions = async () => {
memberOptionsState.value = "loading";
try {
const rows = await genealogyMemberApi.getMemberOptions(genealogyId.value, {
requestController: memberOptionsRequestController,
});
if (!pageActive) return;
memberOptions.value = rows
.filter((item) => item.eligible)
.map((item) => ({
value: item.appUserId,
label: item.relationName
? `${item.memberName} · ${item.relationName}`
: item.memberName,
}));
memberOptionsState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
memberOptions.value = [];
memberOptionsState.value = "error";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
relationType.value = String(query.relationType || "");
if (
!genealogyId.value ||
(!isFirstMember.value && !personId.value)
) {
addState.value = "error";
errorMessage.value = "这个页面已经过期,请从世系树重新进入。";
return;
}
if (relationType.value && !activeRelationIntent.value) {
addState.value = "error";
errorMessage.value = "暂时无法确定要添加的亲属关系,请从世系树重新选择。";
return;
}
if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label;
}
void loadMemberOptions();
if (isFirstMember.value) {
addState.value = "form";
return;
}
void loadCurrentMember();
});
onUnload(() => {
pageActive = false;
loadSequence += 1;
memberDetailRequestController.abort();
memberOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberCreationRequestController.abort();
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => {
fieldErrors[field] = "";
};
const selectRelation = (event) => {
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
clearError("relation");
};
const optionIndex = findMemberOptionIndex;
const optionLabel = findMemberOptionLabel;
const selectOption = (field, options, event) => {
updateMemberFormOption(addForm, field, options, event);
};
const selectBindingMode = (event) => {
updateMemberBindingMode(addForm, event);
fieldErrors.bindingMode = "";
};
const memberOptionIndex = (value) =>
Math.max(
0,
memberOptions.value.findIndex((item) => item.value === value),
);
const memberOptionLabel = (value) =>
memberOptions.value.find((item) => item.value === value)?.label ||
(memberOptionsState.value === "loading"
? "正在读取"
: memberOptions.value.length
? "请选择可绑定成员"
: "暂无可绑定成员");
const selectBoundMember = (event) => {
addForm.appUserId =
memberOptions.value[Number(event.detail.value)]?.value || "";
fieldErrors.bindingMode = "";
};
const uploadAvatar = async () => {
if (isAvatarUploading.value || isSubmitting.value) return;
isAvatarUploading.value = true;
avatarUploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: avatarUploadRequestController,
});
if (!pageActive) return;
addForm.avatarOssId = receipt.ossId;
avatarFileName.value = receipt.fileName || "头像图片";
} catch (error) {
if (!pageActive) return;
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
avatarUploadError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
} finally {
if (pageActive) 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 ? "" : "请选择与当前成员的关系";
fieldErrors.bindingMode =
addForm.bindingMode === "SPECIFIED" && !addForm.appUserId
? memberOptionsState.value === "error"
? "可绑定成员暂不可用,请稍后重试"
: "请选择可绑定成员"
: "";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
};
const submitAdd = async () => {
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm())
return;
const payload = {
bindingMode: addForm.bindingMode,
...(addForm.bindingMode === "SPECIFIED"
? { appUserId: addForm.appUserId }
: {}),
name: addForm.name,
aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName,
avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
birthPlace: addForm.birthPlace,
deathDate: addForm.deathDate,
deathLunar: addForm.deathLunar,
deathPlace: addForm.deathPlace,
burialPlace: addForm.burialPlace,
personStatus: addForm.personStatus,
biography: addForm.biography,
remark: addForm.remark,
sortOrder: addForm.sortOrder,
...(isFirstMember.value
? { generation: 1 }
: {
...(addForm.generation
? { generation: Number(addForm.generation) }
: {}),
...(relationType.value === memberRelationTypes.SPOUSE
? { relationName: relationLabel.value }
: {}),
}),
};
const submittedRelationType = isFirstMember.value
? ""
: activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
const createAttempt = memberCreationGuard.begin({
genealogyId: genealogyId.value,
personId: isFirstMember.value ? "" : personId.value,
relationType: submittedRelationType,
payload,
});
if (createAttempt === null) {
memberCreationOutcomeUnknown.value = true;
errorMessage.value =
"上次保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
addState.value = "error";
return;
}
isSubmitting.value = true;
try {
if (isFirstMember.value) {
await lineageApi.createPerson(genealogyId.value, payload, {
requestController: memberCreationRequestController,
});
} else {
await lineageApi.createRelatedPerson(
genealogyId.value,
personId.value,
submittedRelationType,
payload,
{ requestController: memberCreationRequestController },
);
}
if (!pageActive) return;
memberCreationCommitted.value = true;
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (!pageActive) return;
if (
!memberCreationCommitted.value &&
memberCreationGuard.recordFailure(createAttempt, error)
) {
memberCreationOutcomeUnknown.value = true;
errorMessage.value =
"保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
addState.value = "error";
return;
}
if (isRequestCancelled(error)) return;
errorMessage.value = memberCreationCommitted.value
? "成员已经保存,但页面返回失败。请重试返回世系树,不要重复新增。"
: getRequestErrorMessage(error, "成员资料尚未保存,请稍后重试。");
addState.value = "error";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const handleResultAction = () =>
memberCreationCommitted.value || memberCreationOutcomeUnknown.value
? returnTo("T01", { genealogyId: genealogyId.value })
: hasValidContext.value
? (addState.value = "form")
: returnToTree();
const returnToTree = async () => {
const confirmed = hasDraft.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("T01", { genealogyId: genealogyId.value });
};
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.add-relative-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.add-relative-page__header {
z-index: 3;
}
.add-relative-panel {
@include adaptive-tree-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 28rpx;
padding: 7.5% 8%;
}
.form-eyebrow {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
letter-spacing: 3rpx;
}
.form-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
line-height: 1.35;
}
.form-copy,
.form-note {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.member-context,
.form-field {
@include adaptive-tree-field;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 78rpx;
align-items: center;
gap: 20rpx;
margin-top: 14rpx;
padding: 12rpx 22rpx;
}
.member-context text:first-child,
.form-field > text:first-child {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
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: clamp(15px, 24rpx, 18px);
line-height: 1.45;
text-align: right;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
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: clamp(13px, 21rpx, 16px);
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, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.form-field--summary {
align-items: start;
min-height: 200rpx;
}
.form-placeholder {
color: #a79884;
}
.field-error {
display: block;
margin: 5rpx 18rpx 0;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: max(1.35em, clamp(17px, 32rpx, 22px));
}
.form-note {
text-align: center;
}
.form-action {
display: grid;
width: 100%;
min-height: 76rpx;
margin-top: 18rpx;
}
.form-action image,
.form-action text {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.form-action text {
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff9ed;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.add-result {
margin-top: 30%;
text-align: center;
}
.add-result .form-eyebrow,
.add-result .form-copy {
text-align: center;
}
.add-result .form-action {
width: 420rpx;
max-width: 100%;
margin-right: auto;
margin-left: auto;
}
@media (min-width: 400px) {
.add-relative-panel {
width: calc(100% - 48rpx);
}
}
</style>