feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+922
View File
@@ -0,0 +1,922 @@
<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>