feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
eyebrow="内容密码"
|
||||
title="找回内容密码"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="!isBusy"
|
||||
@confirm="requestClose"
|
||||
@cancel="requestClose"
|
||||
>
|
||||
<view class="recovery-dialog">
|
||||
<AppLoading v-if="capabilityState === 'loading'" text="正在核对找回方式" />
|
||||
<template v-else-if="capabilityState === 'ready' && capability.enabled">
|
||||
<text class="recovery-dialog__copy"
|
||||
>验证码将发送至当前账号绑定手机号 {{ capability.maskedPhone }}。验证通过后可设置新的内容密码。</text
|
||||
>
|
||||
<view class="recovery-dialog__code-row">
|
||||
<input
|
||||
v-model.trim="smsCode"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
placeholder="4位短信验证码"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="isBusy || cooldownSeconds > 0"
|
||||
:label="codeButtonLabel"
|
||||
@click="sendRecoveryCode"
|
||||
/>
|
||||
</view>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="新的内容密码(8至128位)"
|
||||
/>
|
||||
<input
|
||||
v-model="confirmedPassword"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="再次输入新的内容密码"
|
||||
/>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isBusy"
|
||||
:label="resetting ? '正在重置' : '验证并重置密码'"
|
||||
@click="resetContentPassword"
|
||||
/>
|
||||
</template>
|
||||
<view v-else class="recovery-dialog__state">
|
||||
<text>{{ capabilityError || capability.disabledReason || "当前内容暂不支持密码找回。" }}</text>
|
||||
<AppButton
|
||||
v-if="capabilityState === 'error'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="重试"
|
||||
@click="loadCapability"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="formError" class="recovery-dialog__error" role="alert">{{ formError }}</text>
|
||||
<text v-if="resultNotice" class="recovery-dialog__notice" role="status">{{ resultNotice }}</text>
|
||||
<text class="recovery-dialog__security"
|
||||
>服务端必须校验当前账号、资源查看权限和短信票据,并对发送与重置操作限流、审计。</text
|
||||
>
|
||||
</view>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import { contentPasswordRecoveryApi } from "@/services/api/content-password-recovery-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
genealogyId: { type: String, required: true },
|
||||
resourceType: { type: String, required: true },
|
||||
resourceId: { type: String, required: true },
|
||||
});
|
||||
const emit = defineEmits(["close", "complete", "busy-change"]);
|
||||
|
||||
const capabilityState = ref("idle");
|
||||
const capability = reactive({
|
||||
enabled: false,
|
||||
maskedPhone: "",
|
||||
disabledReason: "",
|
||||
});
|
||||
const capabilityError = ref("");
|
||||
const formError = ref("");
|
||||
const resultNotice = ref("");
|
||||
const smsCode = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmedPassword = ref("");
|
||||
const cooldownSeconds = ref(0);
|
||||
const sendingCode = ref(false);
|
||||
const resetting = ref(false);
|
||||
const capabilityController = createRequestController();
|
||||
const codeController = createRequestController();
|
||||
const resetController = createRequestController();
|
||||
const resetGuard = createNonIdempotentWriteGuard();
|
||||
let cooldownTimer = null;
|
||||
let componentActive = true;
|
||||
|
||||
const isBusy = computed(() => sendingCode.value || resetting.value);
|
||||
const codeButtonLabel = computed(() =>
|
||||
sendingCode.value
|
||||
? "正在发送"
|
||||
: cooldownSeconds.value > 0
|
||||
? `${cooldownSeconds.value}s 后重发`
|
||||
: "发送验证码",
|
||||
);
|
||||
|
||||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||||
|
||||
const stopCooldown = () => {
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
};
|
||||
const startCooldown = (seconds) => {
|
||||
stopCooldown();
|
||||
cooldownSeconds.value = seconds;
|
||||
if (seconds <= 0) return;
|
||||
cooldownTimer = setInterval(() => {
|
||||
cooldownSeconds.value = Math.max(0, cooldownSeconds.value - 1);
|
||||
if (cooldownSeconds.value === 0) stopCooldown();
|
||||
}, 1000);
|
||||
};
|
||||
const resetForm = () => {
|
||||
smsCode.value = "";
|
||||
newPassword.value = "";
|
||||
confirmedPassword.value = "";
|
||||
formError.value = "";
|
||||
resultNotice.value = "";
|
||||
};
|
||||
const loadCapability = async () => {
|
||||
capabilityController.abort();
|
||||
capabilityState.value = "loading";
|
||||
capabilityError.value = "";
|
||||
try {
|
||||
const result = await contentPasswordRecoveryApi.getCapability(
|
||||
props.genealogyId,
|
||||
props.resourceType,
|
||||
props.resourceId,
|
||||
{ requestController: capabilityController },
|
||||
);
|
||||
if (!componentActive || !props.visible) return;
|
||||
Object.assign(capability, result);
|
||||
startCooldown(result.cooldownSeconds);
|
||||
capabilityState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!componentActive || !props.visible || isRequestCancelled(error)) return;
|
||||
capabilityError.value = getRequestErrorMessage(error, "找回方式暂时无法读取,请稍后重试。");
|
||||
capabilityState.value = "error";
|
||||
}
|
||||
};
|
||||
const sendRecoveryCode = async () => {
|
||||
if (!capability.enabled || sendingCode.value || resetting.value || cooldownSeconds.value > 0) return;
|
||||
sendingCode.value = true;
|
||||
formError.value = "";
|
||||
try {
|
||||
const delivery = await contentPasswordRecoveryApi.sendCode(
|
||||
props.genealogyId,
|
||||
props.resourceType,
|
||||
props.resourceId,
|
||||
{ requestController: codeController },
|
||||
);
|
||||
if (!componentActive || !props.visible) return;
|
||||
startCooldown(delivery.cooldownSeconds);
|
||||
resultNotice.value = `验证码已发送至 ${capability.maskedPhone}`;
|
||||
} catch (error) {
|
||||
if (componentActive && props.visible && !isRequestCancelled(error)) {
|
||||
formError.value = getRequestErrorMessage(error, "验证码发送失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (componentActive) sendingCode.value = false;
|
||||
}
|
||||
};
|
||||
const resetContentPassword = async () => {
|
||||
if (!capability.enabled || isBusy.value) return;
|
||||
if (!/^\d{4}$/.test(smsCode.value)) {
|
||||
formError.value = "请输入4位短信验证码。";
|
||||
return;
|
||||
}
|
||||
if (newPassword.value.length < 8 || newPassword.value.length > 128) {
|
||||
formError.value = "请输入8至128位新的内容密码。";
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmedPassword.value) {
|
||||
formError.value = "两次输入的新密码不一致。";
|
||||
return;
|
||||
}
|
||||
const payload = { smsCode: smsCode.value, newPassword: newPassword.value };
|
||||
const resetAttempt = resetGuard.begin(payload);
|
||||
if (resetAttempt === null) {
|
||||
formError.value = "上次重置结果待确认,请先关闭窗口并尝试使用新密码解锁。";
|
||||
return;
|
||||
}
|
||||
resetting.value = true;
|
||||
formError.value = "";
|
||||
try {
|
||||
await contentPasswordRecoveryApi.resetPassword(
|
||||
props.genealogyId,
|
||||
props.resourceType,
|
||||
props.resourceId,
|
||||
payload,
|
||||
{ requestController: resetController },
|
||||
);
|
||||
if (!componentActive || !props.visible) return;
|
||||
emit("complete", payload.newPassword);
|
||||
emit("close");
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const isOutcomeUnknown = resetGuard.recordFailure(resetAttempt, error);
|
||||
if (!componentActive || !props.visible) return;
|
||||
if (isOutcomeUnknown) {
|
||||
emit("complete", payload.newPassword);
|
||||
formError.value = "重置结果待确认,请关闭窗口后尝试使用新密码解锁,不要重复提交。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
formError.value = getRequestErrorMessage(error, "内容密码重置失败,请检查验证码后重试。");
|
||||
} finally {
|
||||
if (componentActive) resetting.value = false;
|
||||
}
|
||||
};
|
||||
const requestClose = () => {
|
||||
if (!isBusy.value) emit("close");
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
capabilityController.abort();
|
||||
codeController.abort();
|
||||
resetController.abort();
|
||||
stopCooldown();
|
||||
resetForm();
|
||||
capabilityState.value = "idle";
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
void loadCapability();
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
stopCooldown();
|
||||
capabilityController.abort();
|
||||
codeController.abort();
|
||||
resetController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recovery-dialog { width: 100%; margin-top: 16rpx; text-align: left; }
|
||||
.recovery-dialog__copy,
|
||||
.recovery-dialog__security,
|
||||
.recovery-dialog__state text,
|
||||
.recovery-dialog__error,
|
||||
.recovery-dialog__notice { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||||
.recovery-dialog input { width: 100%; min-height: var(--app-touch-min); margin-top: 16rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(142, 95, 41, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .68); }
|
||||
.recovery-dialog__code-row { display: flex; align-items: center; margin-top: 16rpx; gap: 12rpx; }
|
||||
.recovery-dialog__code-row input { min-width: 0; flex: 1; margin-top: 0; }
|
||||
.recovery-dialog__code-row .app-button { width: auto; flex: 0 0 auto; }
|
||||
.recovery-dialog > .app-button { margin-top: 20rpx; }
|
||||
.recovery-dialog__state .app-button { width: auto; margin-top: 16rpx; }
|
||||
.recovery-dialog__error { margin-top: 14rpx; color: $brand-red; }
|
||||
.recovery-dialog__notice { margin-top: 14rpx; color: #426b58; }
|
||||
.recovery-dialog__security { margin-top: 18rpx; padding-top: 14rpx; border-top: 1rpx solid rgba(142, 95, 41, .18); font-size: clamp(12px, 19rpx, 14px); }
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<image
|
||||
class="module-page-background__image"
|
||||
:src="source"
|
||||
mode="widthFix"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -48,6 +48,7 @@ const source = computed(() => sources[props.module] || sources.profile);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
.module-page-background--family .module-page-background__image {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<view v-if="visible" class="vertical-video-viewer">
|
||||
<view class="vertical-video-viewer__header">
|
||||
<button aria-label="关闭上下滑动播放" @click="close">返回</button>
|
||||
<text>{{ title }}</text>
|
||||
<text>{{ activeIndex + 1 }}/{{ videos.length }}</text>
|
||||
</view>
|
||||
|
||||
<swiper
|
||||
class="vertical-video-viewer__swiper"
|
||||
vertical
|
||||
:current="activeIndex"
|
||||
:duration="260"
|
||||
@change="changeVideo"
|
||||
>
|
||||
<swiper-item v-for="(video, index) in videos" :key="video.id">
|
||||
<view class="vertical-video-viewer__slide">
|
||||
<video
|
||||
:id="videoElementId(video)"
|
||||
class="vertical-video-viewer__player"
|
||||
:src="video.videoFile.accessUrl"
|
||||
:poster="video.coverFile?.accessUrl || ''"
|
||||
:autoplay="visible && index === activeIndex"
|
||||
loop
|
||||
controls
|
||||
object-fit="contain"
|
||||
/>
|
||||
<view class="vertical-video-viewer__summary">
|
||||
<text class="vertical-video-viewer__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="vertical-video-viewer__description">{{ video.description }}</text>
|
||||
<view class="vertical-video-viewer__actions">
|
||||
<button :disabled="actionBusy" @click="requestLike(video)">
|
||||
{{ video.likedByCurrentUser ? "已赞" : "点赞" }} {{ video.likeCount || 0 }}
|
||||
</button>
|
||||
<button :disabled="actionBusy" @click="requestComments(video)">
|
||||
评论 {{ video.commentCount || 0 }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
videos: { type: Array, default: () => [] },
|
||||
initialVideoId: { type: [String, Number], default: "" },
|
||||
title: { type: String, default: "视频播放" },
|
||||
actionBusy: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(["close", "like", "comments"]);
|
||||
const activeIndex = ref(0);
|
||||
|
||||
const videoElementId = (video) => `vertical-video-${String(video.id)}`;
|
||||
const pauseVideo = (index) => {
|
||||
const video = props.videos[index];
|
||||
if (!video) return;
|
||||
uni.createVideoContext(videoElementId(video))?.pause();
|
||||
};
|
||||
const playVideo = (index) => {
|
||||
const video = props.videos[index];
|
||||
if (!video) return;
|
||||
uni.createVideoContext(videoElementId(video))?.play();
|
||||
};
|
||||
const resolveInitialIndex = () => {
|
||||
const requestedId = String(props.initialVideoId || "");
|
||||
const requestedIndex = props.videos.findIndex(
|
||||
(video) => String(video.id) === requestedId,
|
||||
);
|
||||
return requestedIndex >= 0 ? requestedIndex : 0;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (!visible) {
|
||||
pauseVideo(activeIndex.value);
|
||||
return;
|
||||
}
|
||||
activeIndex.value = resolveInitialIndex();
|
||||
await nextTick();
|
||||
playVideo(activeIndex.value);
|
||||
},
|
||||
);
|
||||
|
||||
const changeVideo = async (event) => {
|
||||
const nextIndex = Number(event?.detail?.current ?? 0);
|
||||
if (!Number.isInteger(nextIndex) || nextIndex < 0 || nextIndex >= props.videos.length) return;
|
||||
pauseVideo(activeIndex.value);
|
||||
activeIndex.value = nextIndex;
|
||||
await nextTick();
|
||||
playVideo(activeIndex.value);
|
||||
};
|
||||
const close = () => {
|
||||
pauseVideo(activeIndex.value);
|
||||
emit("close");
|
||||
};
|
||||
const requestLike = (video) => emit("like", video);
|
||||
const requestComments = (video) => {
|
||||
pauseVideo(activeIndex.value);
|
||||
emit("comments", video);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vertical-video-viewer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
background: #080808;
|
||||
}
|
||||
.vertical-video-viewer__header {
|
||||
display: grid;
|
||||
min-height: 88rpx;
|
||||
padding: calc(12rpx + env(safe-area-inset-top)) 24rpx 12rpx;
|
||||
grid-template-columns: 120rpx 1fr 120rpx;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
text-align: center;
|
||||
}
|
||||
.vertical-video-viewer__header button,
|
||||
.vertical-video-viewer__actions button {
|
||||
min-height: 64rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 32rpx;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
.vertical-video-viewer__header button {
|
||||
padding: 0 20rpx;
|
||||
justify-self: start;
|
||||
}
|
||||
.vertical-video-viewer__header text:nth-child(2) {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vertical-video-viewer__header text:last-child {
|
||||
justify-self: end;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.vertical-video-viewer__swiper,
|
||||
.vertical-video-viewer__slide {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.vertical-video-viewer__slide {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
background: #080808;
|
||||
}
|
||||
.vertical-video-viewer__player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.vertical-video-viewer__summary {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 80rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
|
||||
pointer-events: none;
|
||||
}
|
||||
.vertical-video-viewer__summary text {
|
||||
display: block;
|
||||
}
|
||||
.vertical-video-viewer__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.vertical-video-viewer__description {
|
||||
display: -webkit-box !important;
|
||||
overflow: hidden;
|
||||
margin-top: 12rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.55;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.vertical-video-viewer__actions {
|
||||
display: flex;
|
||||
margin-top: 22rpx;
|
||||
gap: 16rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.vertical-video-viewer__actions button {
|
||||
min-width: 150rpx;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vertical-video-viewer__swiper { scroll-behavior: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -374,12 +374,18 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.invitation-manager__row .app-button {
|
||||
width: auto;
|
||||
min-width: 120rpx;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.invitation-manager__row text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.issued-invitation__token {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<image
|
||||
class="genealogy-page-background__art"
|
||||
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
|
||||
mode="widthFix"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -25,6 +25,7 @@
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
|
||||
@click="unlockContent"
|
||||
/>
|
||||
<button class="detail-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码?</button>
|
||||
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -33,18 +34,26 @@
|
||||
<text>关联人物:{{ detailRecord?.personName || "未关联人物" }}</text>
|
||||
<text>记录类型:{{ detailRecord?.typeLabel || "未填写" }}</text>
|
||||
<text>记录日期:{{ detailRecord?.recordDate || "未填写" }}</text>
|
||||
<text v-if="detailRecord?.createTime">创建时间:{{ formatMinuteTimestamp(detailRecord.createTime) }}</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)"
|
||||
/>
|
||||
<template v-for="file in detailRecord.mediaFiles" :key="file.fileId">
|
||||
<video
|
||||
v-if="isVideoFile(file)"
|
||||
:src="file.accessUrl"
|
||||
controls
|
||||
:aria-label="file.fileName || '播放成长记录视频'"
|
||||
/>
|
||||
<image
|
||||
v-else
|
||||
:src="file.accessUrl"
|
||||
mode="aspectFill"
|
||||
role="button"
|
||||
aria-label="查看成长记录图片"
|
||||
@click="previewMedia(file)"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
<view v-if="detailRecord?.canManageProtection" class="detail-protection-actions">
|
||||
<AppButton
|
||||
@@ -87,6 +96,15 @@
|
||||
/>
|
||||
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
|
||||
</AppDialog>
|
||||
<ContentPasswordRecoveryDialog
|
||||
:visible="passwordRecoveryVisible"
|
||||
:genealogy-id="genealogyId"
|
||||
resource-type="GROWTH_RECORD"
|
||||
:resource-id="String(detailRecord?.id || '')"
|
||||
@close="passwordRecoveryVisible = false"
|
||||
@complete="completePasswordRecovery"
|
||||
@busy-change="passwordRecoveryBusy = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -94,6 +112,8 @@ 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 { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -117,6 +137,8 @@ const contentPassword = ref("");
|
||||
const protectionSubmitting = ref(false);
|
||||
const protectionVisible = ref(false);
|
||||
const protectionMode = ref("set");
|
||||
const passwordRecoveryVisible = ref(false);
|
||||
const passwordRecoveryBusy = ref(false);
|
||||
const growthDetailReadRequestController = createRequestController();
|
||||
const growthDetailUnlockRequestController = createRequestController();
|
||||
const growthProtectionMutationRequestController = createRequestController();
|
||||
@@ -136,7 +158,7 @@ const protectionConfirmText = computed(() => {
|
||||
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
|
||||
});
|
||||
const isBusy = computed(() =>
|
||||
detailState.value === "loading" || protectionSubmitting.value,
|
||||
detailState.value === "loading" || protectionSubmitting.value || passwordRecoveryBusy.value,
|
||||
);
|
||||
const hasTransient = computed(() =>
|
||||
Boolean(detailRecord.value) || protectionVisible.value,
|
||||
@@ -155,6 +177,7 @@ const clearDetailState = () => {
|
||||
detailError.value = "";
|
||||
contentPassword.value = "";
|
||||
protectionVisible.value = false;
|
||||
passwordRecoveryVisible.value = false;
|
||||
};
|
||||
const applyRecordDetail = (recordDetail) => {
|
||||
detailRecord.value = {
|
||||
@@ -231,6 +254,10 @@ const unlockContent = async () => {
|
||||
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const completePasswordRecovery = (newPassword) => {
|
||||
contentPassword.value = newPassword;
|
||||
detailError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||||
};
|
||||
|
||||
const openProtection = (mode) => {
|
||||
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
@@ -315,6 +342,11 @@ const close = () => {
|
||||
return true;
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (passwordRecoveryVisible.value) {
|
||||
if (passwordRecoveryBusy.value) return false;
|
||||
passwordRecoveryVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (protectionVisible.value) {
|
||||
closeProtection();
|
||||
return !protectionSubmitting.value;
|
||||
@@ -323,11 +355,17 @@ const closeTransient = () => {
|
||||
};
|
||||
const previewMedia = (file) => {
|
||||
const urls = detailRecord.value?.mediaFiles
|
||||
?.filter((mediaFile) => !isVideoFile(mediaFile))
|
||||
?.map((mediaFile) => mediaFile.accessUrl)
|
||||
.filter(Boolean) || [];
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
const isVideoFile = (file) => {
|
||||
const mediaType = String(file?.mediaType || "").toLowerCase();
|
||||
const fileName = String(file?.fileName || "");
|
||||
return mediaType === "video" || mediaType.startsWith("video/") || /\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(fileName);
|
||||
};
|
||||
|
||||
defineExpose({ closeTransient, open });
|
||||
|
||||
@@ -365,7 +403,8 @@ onUnmounted(() => {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10rpx;
|
||||
}
|
||||
.detail-media image {
|
||||
.detail-media image,
|
||||
.detail-media video {
|
||||
width: 100%;
|
||||
height: 150rpx;
|
||||
border-radius: 8rpx;
|
||||
@@ -383,6 +422,8 @@ onUnmounted(() => {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.detail-recovery-link { display: block; min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.detail-recovery-link::after { border: 0; }
|
||||
.detail-protection-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
label="新建证件档案"
|
||||
@click="openDocumentCreate"
|
||||
/>
|
||||
<text v-if="!documents.length" class="document-dialog-empty">这位成员还没有可查看的重要证件</text>
|
||||
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
|
||||
<view
|
||||
v-for="documentSummary in documents"
|
||||
:key="documentSummary.documentId"
|
||||
@@ -34,7 +34,7 @@
|
||||
<view>
|
||||
<text>{{ documentSummary.documentTitle }}</text>
|
||||
<text
|
||||
>{{ documentTypeLabel(documentSummary.documentType) }}{{
|
||||
>{{ documentSummary.lineagePersonName ? `${documentSummary.lineagePersonName} · ` : "" }}{{ documentTypeLabel(documentSummary.documentType) }}{{
|
||||
documentSummary.maskedIdentifier
|
||||
? ` · ${documentSummary.maskedIdentifier}`
|
||||
: ""
|
||||
@@ -56,6 +56,7 @@
|
||||
<view class="document-unlock-action" role="button" aria-label="解锁重要证件" @click="unlockDocument">
|
||||
<text>{{ documentState === 'submitting' ? '正在解锁…' : '解锁并查看' }}</text>
|
||||
</view>
|
||||
<button class="document-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码?</button>
|
||||
</template>
|
||||
<template v-else-if="documentDialogMode === 'detail' && selectedDocument">
|
||||
<view class="document-detail-row"><text>证件类型</text><text>{{ documentTypeLabel(selectedDocument.documentType) }}</text></view>
|
||||
@@ -71,12 +72,12 @@
|
||||
<text>{{ resourceUsageLabel(resource.usageType) }}</text>
|
||||
<view class="document-resource-actions">
|
||||
<button @click="openDocumentResource(resource)">查看</button>
|
||||
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="replaceDocumentResource(resource)">替换</button>
|
||||
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="requestDeleteDocumentResource(resource)">删除</button>
|
||||
<button v-if="selectedDocument.canEdit" @click="replaceDocumentResource(resource)">替换</button>
|
||||
<button v-if="selectedDocument.canEdit" @click="requestDeleteDocumentResource(resource)">删除</button>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
|
||||
<view v-if="member?.canManageDocuments" class="document-manage-actions">
|
||||
<view v-if="selectedDocument.canEdit || selectedDocument.canDelete" class="document-manage-actions">
|
||||
<AppButton v-if="selectedDocument.canEdit" compact label="编辑资料" @click="openDocumentEdit" />
|
||||
<AppButton v-if="selectedDocument.canEdit" compact type="secondary" label="添加文件" @click="addDocumentResource" />
|
||||
<AppButton
|
||||
@@ -164,6 +165,15 @@
|
||||
@confirm="disableDocumentPassword"
|
||||
@cancel="documentPasswordDisableVisible = false"
|
||||
/>
|
||||
<ContentPasswordRecoveryDialog
|
||||
:visible="passwordRecoveryVisible"
|
||||
:genealogy-id="genealogyId"
|
||||
resource-type="PERSON_DOCUMENT"
|
||||
:resource-id="String(selectedDocument?.documentId || '')"
|
||||
@close="passwordRecoveryVisible = false"
|
||||
@complete="completePasswordRecovery"
|
||||
@busy-change="passwordRecoveryBusy = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -172,11 +182,12 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||
import {
|
||||
PERSON_DOCUMENT_RESOURCE_USAGE,
|
||||
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS,
|
||||
PERSON_DOCUMENT_TYPE_OPTIONS as documentTypeOptions
|
||||
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS
|
||||
} from "@/services/api/person-document-contract.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -188,7 +199,7 @@ import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guar
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: { type: String, required: true },
|
||||
personId: { type: String, required: true },
|
||||
personId: { type: String, default: "" },
|
||||
member: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
@@ -197,6 +208,7 @@ const documentDialogVisible = ref(false);
|
||||
const documentDialogMode = ref("list");
|
||||
const documentState = ref("idle");
|
||||
const documents = ref([]);
|
||||
const documentTypeOptions = ref([]);
|
||||
const selectedDocument = ref(null);
|
||||
const documentPassword = ref("");
|
||||
const documentPasswordConfirm = ref("");
|
||||
@@ -205,6 +217,8 @@ const documentError = ref("");
|
||||
const documentDeleteVisible = ref(false);
|
||||
const documentResourceDeleteTarget = ref(null);
|
||||
const documentPasswordDisableVisible = ref(false);
|
||||
const passwordRecoveryVisible = ref(false);
|
||||
const passwordRecoveryBusy = ref(false);
|
||||
const documentUploadReceipt = ref(null);
|
||||
const documentForm = reactive({
|
||||
documentType: "id_card",
|
||||
@@ -213,6 +227,7 @@ const documentForm = reactive({
|
||||
description: "",
|
||||
});
|
||||
const documentListRequestController = createRequestController();
|
||||
const documentTypeRequestController = createRequestController();
|
||||
const documentDetailRequestController = createRequestController();
|
||||
const documentDraftUploadRequestController = createRequestController();
|
||||
const documentSaveRequestController = createRequestController();
|
||||
@@ -239,7 +254,9 @@ const documentDialogTitle = computed(() => {
|
||||
case "detail":
|
||||
return selectedDocument.value?.documentTitle || "证件详情";
|
||||
default:
|
||||
return `${member.value?.name || "成员"}的重要证件`;
|
||||
return props.personId
|
||||
? `${member.value?.name || "成员"}的重要证件`
|
||||
: "家谱重要证件";
|
||||
}
|
||||
});
|
||||
const documentDialogMessage = computed(() => {
|
||||
@@ -255,20 +272,16 @@ const documentDialogMessage = computed(() => {
|
||||
}
|
||||
});
|
||||
const documentTypeLabel = (documentType) =>
|
||||
({
|
||||
id_card: "身份证",
|
||||
household_register: "户口簿",
|
||||
birth_certificate: "出生证明",
|
||||
marriage_certificate: "结婚证",
|
||||
other: "其他证件",
|
||||
})[documentType] || "重要证件";
|
||||
documentTypeOptions.value.find((option) => option.value === documentType)?.label ||
|
||||
documents.value.find((document) => document.documentType === documentType)?.documentTypeLabel ||
|
||||
"重要证件";
|
||||
const documentTypeLabels = computed(() =>
|
||||
documentTypeOptions.map((documentType) => documentType.label),
|
||||
documentTypeOptions.value.map((documentType) => documentType.label),
|
||||
);
|
||||
const documentTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
documentTypeOptions.findIndex(
|
||||
documentTypeOptions.value.findIndex(
|
||||
(documentType) => documentType.value === documentForm.documentType,
|
||||
),
|
||||
),
|
||||
@@ -280,11 +293,12 @@ const hasTransient = computed(() =>
|
||||
documentDialogVisible.value ||
|
||||
documentDeleteVisible.value ||
|
||||
documentResourceDeleteTarget.value ||
|
||||
documentPasswordDisableVisible.value,
|
||||
documentPasswordDisableVisible.value ||
|
||||
passwordRecoveryVisible.value,
|
||||
),
|
||||
);
|
||||
const isBusy = computed(() =>
|
||||
documentState.value === "loading" || documentState.value === "submitting",
|
||||
documentState.value === "loading" || documentState.value === "submitting" || passwordRecoveryBusy.value,
|
||||
);
|
||||
const shouldIgnoreDocumentFailure = (error) => !isActive || isRequestCancelled(error);
|
||||
const isCurrentPersonContext = (personId, activeWorkflow) =>
|
||||
@@ -301,6 +315,7 @@ watch(isBusy, (value) => emit("busy-change", value), { immediate: true });
|
||||
const resetDocumentWorkflow = () => {
|
||||
workflowVersion += 1;
|
||||
documentListRequestController.abort();
|
||||
documentTypeRequestController.abort();
|
||||
documentDetailRequestController.abort();
|
||||
documentDraftUploadRequestController.abort();
|
||||
documentSaveRequestController.abort();
|
||||
@@ -315,6 +330,7 @@ const resetDocumentWorkflow = () => {
|
||||
documentDeleteVisible.value = false;
|
||||
documentResourceDeleteTarget.value = null;
|
||||
documentPasswordDisableVisible.value = false;
|
||||
passwordRecoveryVisible.value = false;
|
||||
documentDialogMode.value = "list";
|
||||
documentState.value = "idle";
|
||||
documents.value = [];
|
||||
@@ -336,13 +352,22 @@ const open = async () => {
|
||||
selectedDocument.value = null;
|
||||
documentAccessToken.value = "";
|
||||
try {
|
||||
const documentRows = await personDocumentApi.getPersonDocuments(
|
||||
props.genealogyId,
|
||||
{ lineagePersonId: props.personId },
|
||||
{ requestController: documentListRequestController },
|
||||
);
|
||||
const documentQuery = props.personId
|
||||
? { lineagePersonId: props.personId }
|
||||
: {};
|
||||
const [documentRows, typeOptions] = await Promise.all([
|
||||
personDocumentApi.getPersonDocuments(
|
||||
props.genealogyId,
|
||||
documentQuery,
|
||||
{ requestController: documentListRequestController },
|
||||
),
|
||||
businessDictionaryApi.getBusinessDictionaryOptions("gen_person_document_type", {
|
||||
requestController: documentTypeRequestController,
|
||||
}),
|
||||
]);
|
||||
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
|
||||
documents.value = documentRows;
|
||||
documentTypeOptions.value = typeOptions;
|
||||
documentState.value = "ready";
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -363,9 +388,9 @@ const showDocumentList = () => {
|
||||
documentError.value = "";
|
||||
};
|
||||
const openDocumentCreate = () => {
|
||||
if (!member.value?.canCreateDocument) return;
|
||||
if (!member.value?.canCreateDocument || !documentTypeOptions.value.length) return;
|
||||
Object.assign(documentForm, {
|
||||
documentType: "id_card",
|
||||
documentType: documentTypeOptions.value.find((option) => option.default)?.value || documentTypeOptions.value[0].value,
|
||||
documentTitle: "",
|
||||
maskedIdentifier: "",
|
||||
description: "",
|
||||
@@ -376,19 +401,23 @@ const openDocumentCreate = () => {
|
||||
documentError.value = "";
|
||||
};
|
||||
const openDocumentEdit = () => {
|
||||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
|
||||
if (!selectedDocument.value?.canEdit) return;
|
||||
const currentTypeAvailable = documentTypeOptions.value.some(
|
||||
(option) => option.value === selectedDocument.value.documentType,
|
||||
);
|
||||
Object.assign(documentForm, {
|
||||
documentType: selectedDocument.value.documentType,
|
||||
documentType: currentTypeAvailable ? selectedDocument.value.documentType : "",
|
||||
documentTitle: selectedDocument.value.documentTitle,
|
||||
maskedIdentifier: selectedDocument.value.maskedIdentifier,
|
||||
description: selectedDocument.value.description,
|
||||
});
|
||||
documentDialogMode.value = "edit";
|
||||
documentState.value = "ready";
|
||||
documentError.value = "";
|
||||
documentError.value = currentTypeAvailable ? "" : "原证件类型已停用,请重新选择。";
|
||||
};
|
||||
const selectDocumentType = (event) => {
|
||||
documentForm.documentType = documentTypeOptions[Number(event.detail.value)]?.value || "id_card";
|
||||
documentForm.documentType = documentTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
documentError.value = "";
|
||||
};
|
||||
const uploadDocumentImage = async () => {
|
||||
if (documentState.value === "submitting") return;
|
||||
@@ -527,7 +556,7 @@ const loadDocumentDetail = async (documentId, accessToken) => {
|
||||
}
|
||||
};
|
||||
const updateDocument = async () => {
|
||||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
|
||||
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
|
||||
if (!documentForm.documentTitle.trim()) {
|
||||
documentError.value = "请填写证件名称。";
|
||||
return;
|
||||
@@ -535,13 +564,14 @@ const updateDocument = async () => {
|
||||
documentState.value = "submitting";
|
||||
documentError.value = "";
|
||||
const documentPersonId = props.personId;
|
||||
const targetPersonId = props.personId || selectedDocument.value.lineagePersonId;
|
||||
const documentId = selectedDocument.value.documentId;
|
||||
const activeWorkflow = workflowVersion;
|
||||
try {
|
||||
const updatedDocument = await personDocumentApi.updatePersonDocument(
|
||||
props.genealogyId,
|
||||
documentId,
|
||||
{ lineagePersonId: documentPersonId, ...documentForm },
|
||||
{ lineagePersonId: targetPersonId, ...documentForm },
|
||||
{ requestController: documentSaveRequestController },
|
||||
);
|
||||
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
|
||||
@@ -600,6 +630,10 @@ const unlockDocument = async () => {
|
||||
documentError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
|
||||
}
|
||||
};
|
||||
const completePasswordRecovery = (newPassword) => {
|
||||
documentPassword.value = newPassword;
|
||||
documentError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||||
};
|
||||
const openDocumentResource = async (resource) => {
|
||||
if (!selectedDocument.value) return;
|
||||
const documentPersonId = props.personId;
|
||||
@@ -651,7 +685,7 @@ const openDocumentResource = async (resource) => {
|
||||
}
|
||||
};
|
||||
const addDocumentResource = async () => {
|
||||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
|
||||
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
|
||||
const documentPersonId = props.personId;
|
||||
const documentId = selectedDocument.value.documentId;
|
||||
const activeWorkflow = workflowVersion;
|
||||
@@ -711,7 +745,7 @@ const addDocumentResource = async () => {
|
||||
}
|
||||
};
|
||||
const replaceDocumentResource = async (resource) => {
|
||||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
|
||||
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
|
||||
documentState.value = "submitting";
|
||||
documentError.value = "";
|
||||
const documentPersonId = props.personId;
|
||||
@@ -753,7 +787,7 @@ const replaceDocumentResource = async (resource) => {
|
||||
}
|
||||
};
|
||||
const requestDeleteDocumentResource = (resource) => {
|
||||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
|
||||
if (!selectedDocument.value?.canEdit) return;
|
||||
documentResourceDeleteTarget.value = resource;
|
||||
};
|
||||
const confirmDeleteDocumentResource = async () => {
|
||||
@@ -785,7 +819,7 @@ const confirmDeleteDocumentResource = async () => {
|
||||
}
|
||||
};
|
||||
const openDocumentPassword = () => {
|
||||
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
|
||||
if (!selectedDocument.value?.canEdit) return;
|
||||
documentPassword.value = "";
|
||||
documentPasswordConfirm.value = "";
|
||||
documentError.value = "";
|
||||
@@ -861,7 +895,7 @@ const handleDocumentConfirm = () => {
|
||||
resetDocumentWorkflow();
|
||||
};
|
||||
const requestDeleteDocument = () => {
|
||||
if (!selectedDocument.value?.canDelete || !member.value?.canManageDocuments) return;
|
||||
if (!selectedDocument.value?.canDelete) return;
|
||||
documentDeleteVisible.value = true;
|
||||
};
|
||||
const confirmDeleteDocument = async () => {
|
||||
@@ -891,6 +925,11 @@ const confirmDeleteDocument = async () => {
|
||||
}
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (passwordRecoveryVisible.value) {
|
||||
if (passwordRecoveryBusy.value) return false;
|
||||
passwordRecoveryVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (documentResourceDeleteTarget.value) {
|
||||
documentResourceDeleteTarget.value = null;
|
||||
return true;
|
||||
@@ -973,6 +1012,8 @@ onUnmounted(() => {
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.document-upload-button::after { border: 0; }
|
||||
.document-recovery-link { display: block; min-height: var(--app-touch-min); margin: 6rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.document-recovery-link::after { border: 0; }
|
||||
.document-manage-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user