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
@@ -0,0 +1,397 @@
<template>
<view class="invitation-summary-card">
<view class="invitation-summary-card__heading">
<view>
<text>活动邀请</text>
<text>仅活动管理员可管理为保护隐私不展示受邀人的个人信息</text>
</view>
<view class="invitation-summary-card__actions">
<AppButton
compact
type="secondary"
:disabled="invitationState === 'loading'"
:label="invitationState === 'loading' ? '加载中' : '查看概览'"
@click="loadInvitationSummary"
/>
<AppButton
compact
type="secondary"
:disabled="inviteeManagerState === 'loading'"
:label="inviteeManagerState === 'loading' ? '加载中' : '管理受邀人'"
@click="openInviteeManager"
/>
</view>
</view>
<AppLoading v-if="invitationState === 'loading'" text="正在加载活动邀请" />
<view v-else-if="invitationState === 'ready'" class="invitation-summary-card__stats">
<text> {{ invitationRows.length }} 条邀请</text>
<text v-for="item in invitationSummary" :key="item.status">
{{ item.label }} {{ item.count }}
</text>
</view>
<view v-else-if="invitationState === 'empty'" class="invitation-summary-card__empty">
<text>暂时没有活动邀请</text>
</view>
<view v-else-if="invitationState === 'error'" class="invitation-summary-card__error">
<text>暂时无法加载活动邀请</text>
<AppButton compact type="secondary" label="重新查看" @click="loadInvitationSummary" />
</view>
<view v-if="inviteeManagerVisible" class="invitee-manager">
<text class="invitee-manager__title">选择受邀成员</text>
<text class="invitee-manager__note"
>仅展示可以邀请的成员保存后会更新尚未处理的活动邀请已接受或拒绝的邀请不受影响</text
>
<AppLoading
v-if="inviteeManagerState === 'loading'"
text="正在加载可邀请成员"
/>
<view v-else-if="inviteeManagerState === 'ready'" class="invitee-manager__options">
<view
v-for="item in inviteeOptions"
:key="item.appUserId"
class="invitee-manager__option"
:class="{ 'invitee-manager__option--selected': selectedInviteeUserIds.includes(item.appUserId) }"
role="checkbox"
:aria-checked="selectedInviteeUserIds.includes(item.appUserId)"
@click="toggleInvitee(item.appUserId)"
>
<text>{{ item.displayName }}</text>
<text v-if="item.memberRole">{{ item.memberRole }}</text>
</view>
<text v-if="unresolvedPendingInviteeCount" class="invitee-manager__error"
> {{ unresolvedPendingInviteeCount }} 位受邀人暂未显示在此列表中原有邀请不会受影响</text
>
<view class="invitee-manager__actions">
<AppButton
type="secondary"
label="取消"
:disabled="inviteeSubmitting"
@click="closeInviteeManager"
/>
<AppButton
:label="inviteeSubmitting ? '正在保存' : '保存受邀人'"
:disabled="inviteeSubmitting || unresolvedPendingInviteeCount > 0"
@click="requestInviteeSave"
/>
</view>
</view>
<view v-else-if="inviteeManagerState === 'empty'" class="invitee-manager__empty">
<text>当前没有可邀请成员</text>
<AppButton type="secondary" label="关闭" @click="closeInviteeManager" />
</view>
<view v-else-if="inviteeManagerState === 'error'" class="invitee-manager__error">
<text>暂时无法加载可邀请成员</text>
<AppButton compact type="secondary" label="重新查看" @click="openInviteeManager" />
</view>
</view>
<AppDialog
:visible="inviteeConfirmVisible"
eyebrow="邀请确认"
title="保存本次受邀人调整?"
message="保存后会更新尚未处理的活动邀请;已接受或拒绝的邀请不会受影响。"
:confirm-text="inviteeSubmitting ? '正在保存' : '确认保存'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="confirmInviteeSave"
@cancel="inviteeConfirmVisible = false"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
CEREMONY_INVITATION_STATUS,
CEREMONY_INVITATION_STATUS_LABELS
} from "@/services/api/ceremony-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { ceremonyApi } from "@/services/api/ceremony-service.js";
const props = defineProps({
genealogyId: { type: String, required: true },
ceremonyId: { type: String, required: true },
});
const emit = defineEmits(["busy-change", "transient-change"]);
const invitationRows = ref([]);
const invitationState = ref("idle");
const inviteeManagerVisible = ref(false);
const inviteeManagerState = ref("idle");
const inviteeOptions = ref([]);
const selectedInviteeUserIds = ref([]);
const inviteeSubmitting = ref(false);
const inviteeConfirmVisible = ref(false);
const invitationListRequestController = createRequestController();
const inviteeOptionsRequestController = createRequestController();
const inviteeSaveRequestController = createRequestController();
let isActive = true;
const hasValidContext = computed(
() => /^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.ceremonyId),
);
const invitationSummary = computed(() => {
const statusCounts = invitationRows.value.reduce((counts, invitation) => {
counts[invitation.inviteStatus] = (counts[invitation.inviteStatus] || 0) + 1;
return counts;
}, {});
return Object.entries(CEREMONY_INVITATION_STATUS_LABELS)
.map(([status, label]) => ({ status, label, count: statusCounts[status] || 0 }))
.filter((summary) => summary.count > 0);
});
const pendingInviteeUserIds = computed(() =>
invitationRows.value
.filter(
(invitation) =>
invitation.inviteStatus === CEREMONY_INVITATION_STATUS.PENDING,
)
.map((invitation) => invitation.userKey)
.filter(Boolean),
);
const unresolvedPendingInviteeCount = computed(
() =>
pendingInviteeUserIds.value.filter(
(userId) => !inviteeOptions.value.some((option) => option.appUserId === userId),
).length,
);
const hasTransient = computed(
() => inviteeConfirmVisible.value || inviteeManagerVisible.value,
);
watch(hasTransient, (value) => emit("transient-change", value), { immediate: true });
watch(inviteeSubmitting, (value) => emit("busy-change", value), { immediate: true });
const loadInvitationSummary = async () => {
if (!hasValidContext.value || invitationState.value === "loading") return;
invitationListRequestController.abort();
invitationState.value = "loading";
try {
const invitations = await ceremonyApi.getCeremonyInvitations(
props.genealogyId,
props.ceremonyId,
{ requestController: invitationListRequestController },
);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
invitationState.value = "error";
}
};
const closeInviteeManager = () => {
if (inviteeSubmitting.value) return;
inviteeConfirmVisible.value = false;
inviteeManagerVisible.value = false;
};
const openInviteeManager = async () => {
if (!hasValidContext.value || inviteeManagerState.value === "loading") return;
inviteeManagerVisible.value = true;
inviteeManagerState.value = "loading";
invitationListRequestController.abort();
inviteeOptionsRequestController.abort();
try {
const [invitations, options] = await Promise.all([
ceremonyApi.getCeremonyInvitations(props.genealogyId, props.ceremonyId, {
requestController: invitationListRequestController,
}),
ceremonyApi.getCeremonyInviteeOptions(props.genealogyId, props.ceremonyId, {
requestController: inviteeOptionsRequestController,
}),
]);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
inviteeOptions.value = options.filter((option) => option.eligible);
selectedInviteeUserIds.value = pendingInviteeUserIds.value.slice();
inviteeManagerState.value = inviteeOptions.value.length ? "ready" : "empty";
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
inviteeOptions.value = [];
selectedInviteeUserIds.value = [];
inviteeManagerState.value = "error";
}
};
const toggleInvitee = (appUserId) => {
if (inviteeSubmitting.value) return;
selectedInviteeUserIds.value = selectedInviteeUserIds.value.includes(appUserId)
? selectedInviteeUserIds.value.filter((selectedUserId) => selectedUserId !== appUserId)
: [...selectedInviteeUserIds.value, appUserId];
};
const requestInviteeSave = () => {
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
inviteeConfirmVisible.value = true;
};
const confirmInviteeSave = async () => {
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
inviteeSubmitting.value = true;
try {
const invitations = await ceremonyApi.replaceCeremonyInvitees(
props.genealogyId,
props.ceremonyId,
{ inviteeUserIds: selectedInviteeUserIds.value },
{ requestController: inviteeSaveRequestController },
);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
inviteeConfirmVisible.value = false;
inviteeManagerVisible.value = false;
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
inviteeManagerState.value = "error";
inviteeConfirmVisible.value = false;
} finally {
if (isActive) inviteeSubmitting.value = false;
}
};
const closeTransient = () => {
if (inviteeConfirmVisible.value) {
inviteeConfirmVisible.value = false;
return true;
}
if (inviteeManagerVisible.value) {
closeInviteeManager();
return true;
}
return false;
};
defineExpose({ closeTransient });
onUnmounted(() => {
isActive = false;
invitationListRequestController.abort();
inviteeOptionsRequestController.abort();
inviteeSaveRequestController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.invitation-summary-card {
@include adaptive-records-content;
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 18rpx;
padding: 28rpx 32rpx;
}
.invitation-summary-card__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.invitation-summary-card__heading > view { min-width: 0; flex: 1; }
.invitation-summary-card__actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10rpx;
}
.invitation-summary-card__heading text,
.invitation-summary-card__stats text,
.invitation-summary-card__empty text,
.invitation-summary-card__error > text { display: block; }
.invitation-summary-card__heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.invitation-summary-card__heading text:last-child,
.invitation-summary-card__empty,
.invitation-summary-card__error {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.invitation-summary-card__stats { display: flex; flex-wrap: wrap; gap: 12rpx; }
.invitation-summary-card__stats text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(181, 137, 63, 0.1);
color: $ink;
font-size: clamp(13px, 21rpx, 16px);
}
.invitation-summary-card__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
color: #a22b20;
}
.invitee-manager {
padding-top: 18rpx;
border-top: 1rpx solid rgba(181, 137, 63, 0.32);
}
.invitee-manager__title,
.invitee-manager__note,
.invitee-manager__option text,
.invitee-manager__empty text,
.invitee-manager__error > text { display: block; }
.invitee-manager__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.invitee-manager__note,
.invitee-manager__empty,
.invitee-manager__error {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.invitee-manager__options {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 16rpx;
}
.invitee-manager__option {
display: flex;
min-height: 72rpx;
align-items: center;
justify-content: space-between;
gap: 14rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(181, 137, 63, 0.42);
border-radius: 10rpx;
background: rgba(255, 252, 244, 0.56);
}
.invitee-manager__option--selected {
border-color: rgba(159, 23, 15, 0.72);
background: rgba(159, 23, 15, 0.08);
}
.invitee-manager__option text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.invitee-manager__option text:last-child {
flex: 0 0 auto;
color: $ink-muted;
font-size: clamp(12px, 20rpx, 15px);
}
.invitee-manager__error { color: #a22b20; }
.invitee-manager__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 8rpx;
}
.invitee-manager__empty {
display: flex;
flex-direction: column;
gap: 14rpx;
}
</style>
@@ -0,0 +1,399 @@
<template>
<AppDialog
:visible="Boolean(detailRecord)"
eyebrow="成长详情"
:title="detailRecord?.title || '成长记录'"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="close"
@cancel="close"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else-if="detailRecord?.contentProtected && !detailRecord?.contentUnlocked">
<text>这条成长记录已设置内容密码</text>
<input
v-model="contentPassword"
class="detail-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<AppButton
block
:disabled="protectionSubmitting"
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
@click="unlockContent"
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</template>
<template v-else>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
<text>关联人物{{ detailRecord?.personName || "未关联人物" }}</text>
<text>记录类型{{ detailRecord?.typeLabel || "未填写" }}</text>
<text>记录日期{{ detailRecord?.recordDate || "未填写" }}</text>
<text v-if="detailRecord?.remindTime">提醒时间{{ detailRecord.remindTime }}</text>
<text class="detail-content__body">{{ detailRecord?.content || "未填写记录内容" }}</text>
<view v-if="detailRecord?.mediaFiles?.length" class="detail-media">
<image
v-for="file in detailRecord.mediaFiles"
:key="file.fileId"
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看成长记录图片"
@click="previewMedia(file)"
/>
</view>
<view v-if="detailRecord?.canManageProtection" class="detail-protection-actions">
<AppButton
compact
type="secondary"
:label="detailRecord.contentProtected ? '修改内容密码' : '设置内容密码'"
@click="openProtection('set')"
/>
<AppButton
v-if="detailRecord.contentProtected"
compact
type="secondary"
label="关闭内容密码"
@click="openProtection('disable')"
/>
</view>
</template>
</view>
</AppDialog>
<AppDialog
:visible="protectionVisible"
eyebrow="内容密码"
:title="protectionTitle"
:message="protectionMessage"
:confirm-text="protectionConfirmText"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="saveProtection"
@cancel="closeProtection"
>
<input
v-if="protectionMode === 'set'"
v-model="contentPassword"
class="detail-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</AppDialog>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
});
const emit = defineEmits(["busy-change", "transient-change"]);
const detailRecord = ref(null);
const detailState = ref("idle");
const detailError = ref("");
const contentPassword = ref("");
const protectionSubmitting = ref(false);
const protectionVisible = ref(false);
const protectionMode = ref("set");
const growthDetailReadRequestController = createRequestController();
const growthDetailUnlockRequestController = createRequestController();
const growthProtectionMutationRequestController = createRequestController();
let componentActive = true;
const protectionTitle = computed(() => {
if (protectionMode.value === "disable") return "关闭内容密码?";
return detailRecord.value?.contentProtected ? "修改内容密码" : "设置内容密码";
});
const protectionMessage = computed(() =>
protectionMode.value === "disable"
? "关闭后,有权查看记录的成员无需密码即可阅读。"
: "设置8至128位密码,之后查看完整内容需要先验证。",
);
const protectionConfirmText = computed(() => {
if (protectionSubmitting.value) return "正在保存";
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
});
const isBusy = computed(() =>
detailState.value === "loading" || protectionSubmitting.value,
);
const hasTransient = computed(() =>
Boolean(detailRecord.value) || protectionVisible.value,
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
watch(hasTransient, (visible) => emit("transient-change", visible), { immediate: true });
const shouldIgnoreFailure = (cause) =>
!componentActive || isRequestCancelled(cause);
const isCurrentRecord = (recordId) =>
componentActive && String(detailRecord.value?.id || "") === String(recordId);
const clearDetailState = () => {
detailRecord.value = null;
detailState.value = "idle";
detailError.value = "";
contentPassword.value = "";
protectionVisible.value = false;
};
const applyRecordDetail = (recordDetail) => {
detailRecord.value = {
...recordDetail,
typeLabel:
recordDetail.typeLabel || detailRecord.value?.typeLabel || "",
};
};
const open = async (record) => {
if (!record?.id || isBusy.value) return;
const recordId = String(record.id);
growthDetailReadRequestController.abort();
detailRecord.value = record;
detailState.value = "loading";
detailError.value = "";
contentPassword.value = "";
try {
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
"",
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
detailState.value = "ready";
} catch (cause) {
if (shouldIgnoreFailure(cause) || !isCurrentRecord(recordId)) return;
detailState.value = "error";
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
}
};
const unlockContent = async () => {
if (!detailRecord.value?.id || protectionSubmitting.value) return;
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
detailError.value = "请输入8至128位内容密码。";
return;
}
protectionSubmitting.value = true;
detailError.value = "";
const recordId = String(detailRecord.value.id);
let passwordAccepted = false;
try {
const grant = await lifeRecordApi.unlockGrowthRecord(
props.genealogyId,
recordId,
contentPassword.value,
{ requestController: growthDetailUnlockRequestController },
);
if (!isCurrentRecord(recordId)) return;
passwordAccepted = true;
contentPassword.value = "";
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
grant.accessToken,
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
detailState.value = "ready";
} catch (cause) {
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
detailError.value = getRequestErrorMessage(
cause,
passwordAccepted
? "密码已验证,但完整内容暂时无法读取,请稍后重试。"
: "密码不正确,请重新输入。",
);
}
} finally {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const openProtection = (mode) => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
protectionMode.value = mode;
contentPassword.value = "";
detailError.value = "";
protectionVisible.value = true;
};
const closeProtection = () => {
if (protectionSubmitting.value) return;
protectionVisible.value = false;
contentPassword.value = "";
detailError.value = "";
};
const saveProtection = async () => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
if (
protectionMode.value === "set" &&
(contentPassword.value.length < 8 || contentPassword.value.length > 128)
) {
detailError.value = "请输入8至128位内容密码。";
return;
}
protectionSubmitting.value = true;
detailError.value = "";
const recordId = String(detailRecord.value.id);
const contentWillBeProtected = protectionMode.value !== "disable";
let protectionCommitted = false;
try {
if (protectionMode.value === "disable") {
await lifeRecordApi.disableGrowthRecordPassword(
props.genealogyId,
recordId,
{ requestController: growthProtectionMutationRequestController },
);
} else {
await lifeRecordApi.setGrowthRecordPassword(
props.genealogyId,
recordId,
contentPassword.value,
{ requestController: growthProtectionMutationRequestController },
);
}
if (!isCurrentRecord(recordId)) return;
protectionCommitted = true;
protectionVisible.value = false;
contentPassword.value = "";
detailRecord.value = {
...detailRecord.value,
contentProtected: contentWillBeProtected,
contentUnlocked: !contentWillBeProtected,
};
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
"",
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
} catch (cause) {
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
detailError.value = getRequestErrorMessage(
cause,
protectionCommitted
? "内容密码已保存,但详情暂时没有刷新,请稍后重新打开。"
: "内容密码设置没有保存,请稍后重试。",
);
if (protectionCommitted) detailState.value = "ready";
}
} finally {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const close = () => {
if (isBusy.value) return false;
growthDetailReadRequestController.abort();
growthDetailUnlockRequestController.abort();
growthProtectionMutationRequestController.abort();
clearDetailState();
return true;
};
const closeTransient = () => {
if (protectionVisible.value) {
closeProtection();
return !protectionSubmitting.value;
}
return detailRecord.value ? close() : false;
};
const previewMedia = (file) => {
const urls = detailRecord.value?.mediaFiles
?.map((mediaFile) => mediaFile.accessUrl)
.filter(Boolean) || [];
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
defineExpose({ closeTransient, open });
onUnmounted(() => {
componentActive = false;
growthDetailReadRequestController.abort();
growthDetailUnlockRequestController.abort();
growthProtectionMutationRequestController.abort();
});
</script>
<style scoped lang="scss">
.detail-content {
width: 100%;
margin-top: 18rpx;
text-align: left;
}
.detail-content > text {
display: block;
margin-top: 9rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
overflow-wrap: anywhere;
}
.detail-content__body {
padding-top: 10rpx;
border-top: 1rpx solid rgba(142, 95, 41, .2);
color: $ink !important;
white-space: pre-wrap;
}
.detail-media {
display: grid;
margin-top: 16rpx;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10rpx;
}
.detail-media image {
width: 100%;
height: 150rpx;
border-radius: 8rpx;
background: rgba(128, 89, 49, .12);
}
.detail-password-input {
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
margin: 16rpx 0;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, .32);
border-radius: 8rpx;
background: #fffdf8;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.detail-protection-actions {
display: flex;
flex-wrap: wrap;
margin-top: 18rpx;
gap: 10rpx;
}
.detail-error {
display: block;
margin-top: 10rpx;
color: $brand-red !important;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
</style>