feat: migrate app routes and business modules
This commit is contained in:
@@ -35,8 +35,8 @@ const emit = defineEmits(["click"]);
|
||||
|
||||
const skin = computed(() =>
|
||||
props.type === "secondary"
|
||||
? "/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
: "/static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
? "/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
: "/static/assets/foundation/transparent/scroll-primary.png",
|
||||
);
|
||||
|
||||
const handleClick = (event) => {
|
||||
@@ -45,7 +45,7 @@ const handleClick = (event) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/adaptive-frame-profiles.scss";
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-button {
|
||||
display: inline-grid;
|
||||
@@ -70,10 +70,10 @@ const handleClick = (event) => {
|
||||
.app-button--compact {
|
||||
}
|
||||
.app-button--compact.app-button--primary {
|
||||
@include adaptive-scroll-button(primary);
|
||||
@include adaptive.adaptive-scroll-button(primary);
|
||||
}
|
||||
.app-button--compact.app-button--secondary {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
@include adaptive.adaptive-scroll-button(secondary);
|
||||
}
|
||||
.app-button--compact .app-button__skin {
|
||||
display: none;
|
||||
|
||||
@@ -116,7 +116,7 @@ const cancel = () => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/adaptive-frame-profiles.scss";
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-dialog-layer {
|
||||
position: fixed;
|
||||
@@ -136,7 +136,7 @@ const cancel = () => {
|
||||
max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@include adaptive-auth-dialog;
|
||||
@include adaptive.adaptive-auth-dialog;
|
||||
}
|
||||
.app-dialog__content {
|
||||
z-index: 1;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="promotionState === 'error' || promotions.length"
|
||||
class="promotion-strip"
|
||||
:aria-label="title"
|
||||
>
|
||||
<view class="promotion-strip__heading">
|
||||
<view class="promotion-strip__mark" aria-hidden="true"></view>
|
||||
<text>{{ title }}</text>
|
||||
</view>
|
||||
<view v-if="promotionState === 'error'" class="promotion-strip__error">
|
||||
<text>推荐内容暂时没有显示</text>
|
||||
<button @click="loadPromotions">重新加载</button>
|
||||
</view>
|
||||
<scroll-view v-else class="promotion-strip__scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="promotion-strip__list">
|
||||
<view
|
||||
v-for="promotion in promotions"
|
||||
:key="promotion.id"
|
||||
class="promotion-strip__card"
|
||||
:class="{ 'promotion-strip__card--linked': promotion.targetUrl }"
|
||||
:role="promotion.targetUrl ? 'button' : undefined"
|
||||
:aria-label="promotion.targetUrl ? `${promotion.title},查看详情` : promotion.title"
|
||||
@click="openPromotion(promotion)"
|
||||
>
|
||||
<image
|
||||
v-if="promotion.coverFile?.accessUrl"
|
||||
class="promotion-strip__cover"
|
||||
:src="promotion.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="promotion-strip__copy">
|
||||
<text class="promotion-strip__title">{{ promotion.title }}</text>
|
||||
<text v-if="promotion.description" class="promotion-strip__description">
|
||||
{{ promotion.description }}
|
||||
</text>
|
||||
<text v-if="promotion.targetUrl" class="promotion-strip__action">
|
||||
查看详情 ›
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text
|
||||
v-if="operationFeedback"
|
||||
class="promotion-strip__feedback"
|
||||
role="status"
|
||||
>
|
||||
{{ operationFeedback }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({
|
||||
placement: { type: String, required: true },
|
||||
title: { type: String, default: "推荐内容" },
|
||||
});
|
||||
|
||||
const promotions = ref([]);
|
||||
const promotionState = ref("loading");
|
||||
const operationFeedback = ref("");
|
||||
const promotionListController = createRequestController();
|
||||
let isMounted = true;
|
||||
|
||||
const loadPromotions = async () => {
|
||||
promotionListController.abort();
|
||||
promotionState.value = "loading";
|
||||
operationFeedback.value = "";
|
||||
try {
|
||||
const promotionRows = await siteContentApi.getPromotions({
|
||||
placement: props.placement,
|
||||
requestController: promotionListController,
|
||||
});
|
||||
if (!isMounted) return;
|
||||
promotions.value = promotionRows;
|
||||
promotionState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isMounted || isRequestCancelled(error)) return;
|
||||
promotionState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openPromotion = async (promotion) => {
|
||||
const targetUrl = promotion?.targetUrl || "";
|
||||
if (!targetUrl) return;
|
||||
const showExternalLinkError = () => {
|
||||
if (!isMounted) return;
|
||||
operationFeedback.value = "链接暂时打不开,请稍后再试。";
|
||||
};
|
||||
try {
|
||||
await openSiteContentTarget(targetUrl, showExternalLinkError);
|
||||
} catch {
|
||||
if (!isMounted) return;
|
||||
operationFeedback.value = targetUrl.startsWith("/")
|
||||
? "这个页面暂时打不开,请稍后再试。"
|
||||
: "链接暂时打不开,请稍后再试。";
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadPromotions);
|
||||
onBeforeUnmount(() => {
|
||||
isMounted = false;
|
||||
promotionListController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.promotion-strip {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 20rpx 24rpx 28rpx;
|
||||
padding: 22rpx 0 20rpx;
|
||||
border: 1rpx solid rgba(145, 89, 36, 0.34);
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 250, 239, 0.9);
|
||||
box-shadow: 0 7rpx 20rpx rgba(74, 37, 18, 0.08);
|
||||
}
|
||||
.promotion-strip__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 0 22rpx 18rpx;
|
||||
color: #6f1a14;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.promotion-strip__mark {
|
||||
width: 7rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 8rpx;
|
||||
background: linear-gradient(#c89b4b, #8f1b14);
|
||||
}
|
||||
.promotion-strip__scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promotion-strip__list {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
padding: 0 22rpx;
|
||||
}
|
||||
.promotion-strip__card {
|
||||
display: flex;
|
||||
width: 500rpx;
|
||||
min-height: 142rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(125, 83, 45, 0.23);
|
||||
border-radius: 12rpx;
|
||||
background: #fffdf8;
|
||||
white-space: normal;
|
||||
}
|
||||
.promotion-strip__card--linked:active {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.promotion-strip__cover {
|
||||
width: 176rpx;
|
||||
min-height: 142rpx;
|
||||
flex: 0 0 auto;
|
||||
background: #eee4d4;
|
||||
}
|
||||
|
||||
.promotion-strip__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 18rpx 20rpx;
|
||||
}
|
||||
|
||||
.promotion-strip__title {
|
||||
overflow: hidden;
|
||||
color: #402c20;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promotion-strip__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 8rpx;
|
||||
color: #766252;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.promotion-strip__action {
|
||||
margin-top: auto;
|
||||
padding-top: 8rpx;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.promotion-strip__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 0 22rpx;
|
||||
color: #766252;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.promotion-strip__error button {
|
||||
margin: 0;
|
||||
padding: 8rpx 18rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.36);
|
||||
border-radius: 999rpx;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.promotion-strip__error button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.promotion-strip__feedback {
|
||||
display: block;
|
||||
padding: 14rpx 22rpx 0;
|
||||
color: #9f170f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:根页面底部导航;仅维护家谱、家族、我的三项已确认入口及其透明图标。 -->
|
||||
<template>
|
||||
<view class="app-tabbar" role="tablist" aria-label="主要导航">
|
||||
<button
|
||||
@@ -25,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goRoot } from "@/utils/navigation.js";
|
||||
import { goRoot } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({ active: { type: String, required: true } });
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/adaptive-frame-profiles.scss";
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
@@ -31,7 +31,7 @@ defineProps({
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include adaptive-feedback-toast;
|
||||
@include adaptive.adaptive-feedback-toast;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:根页朱砂页头与普通返回页头;根页背景图层由统一基础资产提供。 -->
|
||||
<template>
|
||||
<view class="page-header-slot" :class="{ 'page-header-slot--root': root }">
|
||||
<view class="page-header" :class="{ 'page-header--root': root }">
|
||||
@@ -95,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goBack } from "@/utils/navigation.js";
|
||||
import { goBack } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<view class="auth-shell__header">
|
||||
<image
|
||||
class="auth-shell__header-image"
|
||||
src="/static/assets/modules/auth/opaque/a01-vnext-header-v1.png"
|
||||
src="/static/assets/modules/auth/opaque/auth-header.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="auth-shell__brand-lockup">
|
||||
@@ -78,7 +78,7 @@
|
||||
padding-bottom: var(--app-safe-bottom);
|
||||
background-color: #f7f0e5;
|
||||
background-image:
|
||||
url("/static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png"),
|
||||
url("/static/assets/modules/auth/opaque/sign-in-backdrop.png"),
|
||||
url("/static/assets/foundation/opaque/auth-page-paper.jpg");
|
||||
background-position: center calc(-48.18vw), center top;
|
||||
background-size: 100% auto, 100% auto;
|
||||
@@ -192,13 +192,13 @@ export default {
|
||||
this.completionSent = true;
|
||||
this.generation += 1;
|
||||
this.abortActiveRequest();
|
||||
const data = response && response.data;
|
||||
const verificationData = response && response.data;
|
||||
tac.destroyWindow();
|
||||
this.tac = null;
|
||||
this.$ownerInstance.callMethod("handleTacSuccess", {
|
||||
requestId: requestContext.requestId,
|
||||
validToken: data && data.validToken,
|
||||
expireSeconds: data && data.expireSeconds,
|
||||
validToken: verificationData && verificationData.validToken,
|
||||
expireSeconds: verificationData && verificationData.expireSeconds,
|
||||
});
|
||||
},
|
||||
validFail: (response, captcha, tac) => {
|
||||
@@ -298,8 +298,12 @@ export default {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
settle(data && typeof data === "object" ? data : { code: 502, msg: "安全验证服务返回无效数据" });
|
||||
const parsedResponse = JSON.parse(xhr.responseText);
|
||||
settle(
|
||||
parsedResponse && typeof parsedResponse === "object"
|
||||
? parsedResponse
|
||||
: { code: 502, msg: "安全验证服务返回无效数据" },
|
||||
);
|
||||
} catch (error) {
|
||||
settle({ code: 502, msg: "安全验证服务返回了非 JSON 数据" });
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
<template>
|
||||
<view class="comment-section">
|
||||
<view class="comment-section__heading">
|
||||
<text class="section-title">评论</text>
|
||||
<button class="comment-entry" @click="focusCommentEditor">写评论</button>
|
||||
</view>
|
||||
|
||||
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
|
||||
<view v-else-if="commentState === 'list'" class="comment-list">
|
||||
<view v-for="comment in comments" :key="comment.id" class="comment-card">
|
||||
<view class="comment-card__heading">
|
||||
<text>{{ comment.author }}</text>
|
||||
<text>{{ formatMinuteTimestamp(comment.time) || "刚刚" }}</text>
|
||||
</view>
|
||||
<text class="comment-card__content">{{ comment.content }}</text>
|
||||
<view class="comment-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
label="回复"
|
||||
@click="startReply(comment)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="comment.canDelete && !comment.userDeleted"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click="requestDeleteComment(comment)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="comment.replyCount"
|
||||
compact
|
||||
type="secondary"
|
||||
:label="replyState(comment.id) === 'ready' ? '收起回复' : `查看 ${comment.replyCount} 条回复`"
|
||||
@click="toggleReplies(comment)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="replyState(comment.id) !== 'closed'"
|
||||
class="comment-replies"
|
||||
>
|
||||
<text v-if="replyState(comment.id) === 'loading'" class="reply-state">
|
||||
正在读取回复
|
||||
</text>
|
||||
<view v-else-if="replyState(comment.id) === 'ready'">
|
||||
<text v-if="!replyRows(comment.id).length" class="reply-state">
|
||||
暂未读取到回复
|
||||
</text>
|
||||
<view
|
||||
v-for="reply in replyRows(comment.id)"
|
||||
:key="reply.id"
|
||||
class="reply-card"
|
||||
>
|
||||
<view class="reply-card__heading">
|
||||
<text>{{ reply.author }}</text>
|
||||
<text>{{ formatMinuteTimestamp(reply.time) || "刚刚" }}</text>
|
||||
</view>
|
||||
<text v-if="reply.parentAuthor" class="reply-card__target">
|
||||
回复 {{ reply.parentAuthor }}
|
||||
</text>
|
||||
<text class="reply-card__content">{{ reply.content }}</text>
|
||||
<view class="reply-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
label="回复"
|
||||
@click="startReply(reply, comment)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="reply.canDelete && !reply.userDeleted"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除回复"
|
||||
@click="requestDeleteComment(reply, comment)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="shouldShowReplyMore(comment.id)"
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="replyMoreState(comment.id) === 'loading'"
|
||||
:label="replyMoreLabel(comment.id)"
|
||||
@click="loadMoreReplies(comment)"
|
||||
/>
|
||||
</view>
|
||||
<text v-else class="reply-state reply-state--error">
|
||||
回复暂时无法读取,
|
||||
<text role="button" @click="loadReplies(comment)">重新读取</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="shouldShowCommentMore"
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="commentMoreState === 'loading'"
|
||||
:label="commentMoreLabel"
|
||||
@click="loadMoreComments"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="comment-state-copy">
|
||||
<text>{{ commentStateCopy }}</text>
|
||||
<AppButton
|
||||
v-if="commentState === 'error'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="重新加载评论"
|
||||
@click="reload"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="comment-editor">
|
||||
<view v-if="replyTarget" class="reply-target">
|
||||
<text>正在回复 {{ replyTarget.author }}</text>
|
||||
<text role="button" @click="clearReplyTarget">取消回复</text>
|
||||
</view>
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
auto-height
|
||||
maxlength="1000"
|
||||
:placeholder="replyTarget ? `回复 ${replyTarget.author}` : '写下你的评论'"
|
||||
placeholder-class="comment-editor__placeholder"
|
||||
:focus="commentFocused"
|
||||
@input="commentError = ''"
|
||||
@blur="commentFocused = false"
|
||||
/>
|
||||
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmittingComment"
|
||||
:label="isSubmittingComment ? '正在提交' : replyTarget ? '发表回复' : '发表评论'"
|
||||
@click="submitComment"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="commentDeleteVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除评论"
|
||||
title="确认删除这条评论?"
|
||||
message="删除后评论内容将不再显示。"
|
||||
:confirm-text="deletingComment ? '正在删除' : '确认删除'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
@confirm="confirmDeleteComment"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } 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 { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
feedId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
refreshFeedSummary: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const COMMENT_PAGE_SIZE = 20;
|
||||
const commentState = ref("loading");
|
||||
const comments = ref([]);
|
||||
const currentCommentPage = ref(1);
|
||||
const totalCommentCount = ref(0);
|
||||
const commentMoreState = ref("idle");
|
||||
const replyThreadsByCommentId = ref({});
|
||||
const replyTarget = ref(null);
|
||||
const commentDraft = ref("");
|
||||
const commentFocused = ref(false);
|
||||
const commentError = ref("");
|
||||
const isSubmittingComment = ref(false);
|
||||
const commentDeleteVisible = ref(false);
|
||||
const deletingComment = ref(false);
|
||||
const commentDeleteTarget = ref(null);
|
||||
const commentReadRequestController = createRequestController();
|
||||
const commentSubmissionRequestController = createRequestController();
|
||||
const commentDeletionRequestController = createRequestController();
|
||||
const commentCreateGuard = createNonIdempotentWriteGuard();
|
||||
const replyRequestControllers = new Map();
|
||||
let componentActive = true;
|
||||
|
||||
const hasValidContext = computed(() =>
|
||||
/^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.feedId),
|
||||
);
|
||||
const hasMoreComments = computed(() =>
|
||||
comments.value.length < totalCommentCount.value,
|
||||
);
|
||||
const shouldShowCommentMore = computed(() =>
|
||||
hasMoreComments.value || ["loading", "error"].includes(commentMoreState.value),
|
||||
);
|
||||
const commentMoreLabel = computed(() => {
|
||||
if (commentMoreState.value === "loading") return "正在加载评论";
|
||||
if (commentMoreState.value === "error") return "加载失败,重新加载";
|
||||
return "继续加载评论";
|
||||
});
|
||||
const commentStateCopy = computed(() =>
|
||||
commentState.value === "empty"
|
||||
? "还没有评论,欢迎留下第一句话。"
|
||||
: "评论暂时无法读取,稍后可重新进入本页查看。",
|
||||
);
|
||||
|
||||
const replyRequestController = (commentId) => {
|
||||
const controllerKey = String(commentId);
|
||||
if (!replyRequestControllers.has(controllerKey)) {
|
||||
replyRequestControllers.set(controllerKey, createRequestController());
|
||||
}
|
||||
return replyRequestControllers.get(controllerKey);
|
||||
};
|
||||
const emptyReplyThread = () => ({
|
||||
state: "closed",
|
||||
rows: [],
|
||||
page: 1,
|
||||
total: 0,
|
||||
moreState: "idle",
|
||||
});
|
||||
const replyThread = (commentId) =>
|
||||
replyThreadsByCommentId.value[String(commentId)] || emptyReplyThread();
|
||||
const updateReplyThread = (commentId, changes) => {
|
||||
const threadId = String(commentId);
|
||||
replyThreadsByCommentId.value = {
|
||||
...replyThreadsByCommentId.value,
|
||||
[threadId]: { ...replyThread(threadId), ...changes },
|
||||
};
|
||||
};
|
||||
const replyState = (commentId) => replyThread(commentId).state;
|
||||
const replyRows = (commentId) => replyThread(commentId).rows;
|
||||
const replyHasMore = (commentId) => {
|
||||
const thread = replyThread(commentId);
|
||||
return thread.rows.length < thread.total;
|
||||
};
|
||||
const replyMoreState = (commentId) => replyThread(commentId).moreState;
|
||||
const shouldShowReplyMore = (commentId) =>
|
||||
replyHasMore(commentId) || ["loading", "error"].includes(replyMoreState(commentId));
|
||||
const replyMoreLabel = (commentId) => {
|
||||
const state = replyMoreState(commentId);
|
||||
if (state === "loading") return "正在加载回复";
|
||||
if (state === "error") return "加载失败,重新加载";
|
||||
return "继续加载回复";
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
commentReadRequestController.abort();
|
||||
commentState.value = "loading";
|
||||
currentCommentPage.value = 1;
|
||||
commentMoreState.value = "idle";
|
||||
try {
|
||||
const commentPage = await familyFeedApi.getFeedCommentPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: commentReadRequestController },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
comments.value = commentPage.rows;
|
||||
totalCommentCount.value = commentPage.total;
|
||||
commentMoreState.value =
|
||||
comments.value.length < totalCommentCount.value ? "idle" : "done";
|
||||
commentState.value = commentPage.rows.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreComments = async () => {
|
||||
if (!hasMoreComments.value || commentMoreState.value === "loading") return;
|
||||
commentMoreState.value = "loading";
|
||||
try {
|
||||
const nextPage = currentCommentPage.value + 1;
|
||||
const commentPage = await familyFeedApi.getFeedCommentPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: commentReadRequestController },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
const knownCommentIds = new Set(
|
||||
comments.value.map((comment) => String(comment.id)),
|
||||
);
|
||||
comments.value = comments.value.concat(
|
||||
commentPage.rows.filter(
|
||||
(comment) => !knownCommentIds.has(String(comment.id)),
|
||||
),
|
||||
);
|
||||
currentCommentPage.value = nextPage;
|
||||
totalCommentCount.value = commentPage.total;
|
||||
commentMoreState.value =
|
||||
comments.value.length < totalCommentCount.value ? "idle" : "done";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
commentMoreState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const loadReplies = async (comment) => {
|
||||
const commentId = String(comment?.id || "");
|
||||
if (!hasValidContext.value || !/^[1-9]\d*$/.test(commentId)) return;
|
||||
updateReplyThread(commentId, { state: "loading" });
|
||||
try {
|
||||
const replyPage = await familyFeedApi.getCommentReplyPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
commentId,
|
||||
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: replyRequestController(commentId) },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
updateReplyThread(commentId, {
|
||||
state: "ready",
|
||||
rows: replyPage.rows,
|
||||
page: 1,
|
||||
total: replyPage.total,
|
||||
moreState: replyPage.rows.length < replyPage.total ? "idle" : "done",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
updateReplyThread(commentId, { state: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreReplies = async (comment) => {
|
||||
const commentId = String(comment?.id || "");
|
||||
const thread = replyThread(commentId);
|
||||
if (!replyHasMore(commentId) || thread.moreState === "loading") return;
|
||||
updateReplyThread(commentId, { moreState: "loading" });
|
||||
try {
|
||||
const nextPage = thread.page + 1;
|
||||
const replyPage = await familyFeedApi.getCommentReplyPage(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
commentId,
|
||||
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
|
||||
{ requestController: replyRequestController(commentId) },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
const currentReplies = replyRows(commentId);
|
||||
const knownReplyIds = new Set(
|
||||
currentReplies.map((reply) => String(reply.id)),
|
||||
);
|
||||
const replies = currentReplies.concat(
|
||||
replyPage.rows.filter((reply) => !knownReplyIds.has(String(reply.id))),
|
||||
);
|
||||
updateReplyThread(commentId, {
|
||||
rows: replies,
|
||||
page: nextPage,
|
||||
total: replyPage.total,
|
||||
moreState: replies.length < replyPage.total ? "idle" : "done",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
updateReplyThread(commentId, { moreState: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReplies = (comment) => {
|
||||
const commentId = String(comment?.id || "");
|
||||
if (replyState(commentId) === "ready") {
|
||||
updateReplyThread(commentId, { state: "closed" });
|
||||
return;
|
||||
}
|
||||
void loadReplies(comment);
|
||||
};
|
||||
const startReply = (comment, rootComment = comment) => {
|
||||
// 写入使用实际父评论,刷新则使用所属一级评论;两者不能合并成同一个 ID。
|
||||
replyTarget.value = {
|
||||
id: comment.id,
|
||||
author: comment.author,
|
||||
rootComment,
|
||||
};
|
||||
commentError.value = "";
|
||||
};
|
||||
const clearReplyTarget = () => {
|
||||
replyTarget.value = null;
|
||||
commentError.value = "";
|
||||
};
|
||||
const focusCommentEditor = () => {
|
||||
if (typeof uni?.pageScrollTo !== "function") return;
|
||||
uni.pageScrollTo({
|
||||
selector: ".comment-editor",
|
||||
duration: 240,
|
||||
complete: () => {
|
||||
commentFocused.value = false;
|
||||
nextTick(() => {
|
||||
if (componentActive) commentFocused.value = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const requestDeleteComment = (comment, parent = null) => {
|
||||
if (!comment?.canDelete || comment.userDeleted || deletingComment.value) return;
|
||||
commentDeleteTarget.value = { comment, parent };
|
||||
commentDeleteVisible.value = true;
|
||||
commentError.value = "";
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deletingComment.value) return;
|
||||
commentDeleteVisible.value = false;
|
||||
commentDeleteTarget.value = null;
|
||||
};
|
||||
const confirmDeleteComment = async () => {
|
||||
const deletion = commentDeleteTarget.value;
|
||||
if (!deletion?.comment?.canDelete || deletingComment.value) return;
|
||||
deletingComment.value = true;
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await familyFeedApi.deleteFeedComment(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
deletion.comment.id,
|
||||
{ requestController: commentDeletionRequestController },
|
||||
);
|
||||
deletionCommitted = true;
|
||||
if (!componentActive) return;
|
||||
commentDeleteVisible.value = false;
|
||||
commentDeleteTarget.value = null;
|
||||
await reload();
|
||||
if (deletion.parent) await loadReplies(deletion.parent);
|
||||
const feedRefreshed = await props.refreshFeedSummary();
|
||||
if (componentActive && feedRefreshed === false) {
|
||||
commentError.value = "评论已删除,动态统计暂时未更新。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
if (deletionCommitted) {
|
||||
commentDeleteVisible.value = false;
|
||||
commentDeleteTarget.value = null;
|
||||
commentError.value = "评论已删除,动态统计暂时未更新。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
commentError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"这条评论删除失败,请稍后重试。",
|
||||
);
|
||||
commentDeleteVisible.value = false;
|
||||
} finally {
|
||||
if (componentActive) deletingComment.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmittingComment.value || !hasValidContext.value) return;
|
||||
const commentContent = commentDraft.value.trim();
|
||||
if (!commentContent) {
|
||||
commentError.value = "请填写评论内容";
|
||||
return;
|
||||
}
|
||||
const target = replyTarget.value;
|
||||
const payload = {
|
||||
commentContent,
|
||||
...(target ? { parentCommentId: target.id } : {}),
|
||||
};
|
||||
const createAttempt = commentCreateGuard.begin(payload);
|
||||
if (createAttempt === null) {
|
||||
commentError.value =
|
||||
"上次评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingComment.value = true;
|
||||
commentError.value = "";
|
||||
let commentCommitted = false;
|
||||
try {
|
||||
await familyFeedApi.createFeedComment(
|
||||
props.genealogyId,
|
||||
props.feedId,
|
||||
payload,
|
||||
{ requestController: commentSubmissionRequestController },
|
||||
);
|
||||
commentCommitted = true;
|
||||
if (!componentActive) return;
|
||||
commentDraft.value = "";
|
||||
replyTarget.value = null;
|
||||
await reload();
|
||||
if (target) {
|
||||
await loadReplies(target.rootComment);
|
||||
}
|
||||
const feedRefreshed = await props.refreshFeedSummary();
|
||||
if (componentActive && feedRefreshed === false) {
|
||||
commentError.value = "评论已发布,动态统计暂时未更新。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
if (commentCommitted) {
|
||||
commentError.value = "评论已发布,但动态统计暂时未更新。";
|
||||
return;
|
||||
}
|
||||
if (commentCreateGuard.recordFailure(createAttempt, error)) {
|
||||
commentError.value =
|
||||
"评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
commentError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"评论提交失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
if (componentActive) isSubmittingComment.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ reload });
|
||||
|
||||
onMounted(() => {
|
||||
void reload();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
commentReadRequestController.abort();
|
||||
commentSubmissionRequestController.abort();
|
||||
commentDeletionRequestController.abort();
|
||||
replyRequestControllers.forEach((requestController) => requestController.abort());
|
||||
replyRequestControllers.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.comment-section {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
.comment-section__heading,
|
||||
.comment-card__heading,
|
||||
.reply-card__heading,
|
||||
.reply-target {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.comment-section__heading {
|
||||
align-items: center;
|
||||
}
|
||||
.section-title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-entry {
|
||||
min-width: 144rpx;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 18rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.comment-entry::after {
|
||||
border: 0;
|
||||
}
|
||||
.comment-list,
|
||||
.comment-state-copy {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-card {
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.comment-card__heading text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.reply-card__heading text:first-child,
|
||||
.reply-target text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-card__heading text:last-child,
|
||||
.reply-card__heading text:last-child,
|
||||
.reply-card__target,
|
||||
.reply-state {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.comment-card__content,
|
||||
.reply-card__content {
|
||||
display: block;
|
||||
color: $ink;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.comment-card__content {
|
||||
margin-top: 10rpx;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
}
|
||||
.comment-card__actions,
|
||||
.reply-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.comment-card__actions {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.reply-card__actions {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.comment-card__actions .app-button,
|
||||
.reply-card__actions .app-button {
|
||||
width: auto;
|
||||
min-width: 132rpx;
|
||||
}
|
||||
.comment-card__actions .app-button {
|
||||
min-height: 58rpx;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
.comment-replies {
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
border-left: 4rpx solid rgba(159, 23, 15, 0.3);
|
||||
background: rgba(135, 94, 52, 0.045);
|
||||
}
|
||||
.reply-card + .reply-card {
|
||||
margin-top: 14rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.13);
|
||||
}
|
||||
.reply-card__target,
|
||||
.reply-card__content,
|
||||
.reply-state {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.reply-card__content {
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.reply-state--error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.reply-state--error text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.comment-state-copy {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comment-state-copy .app-button {
|
||||
width: 260rpx;
|
||||
max-width: 100%;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.comment-editor {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.reply-target {
|
||||
margin-bottom: 12rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.22);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(159, 23, 15, 0.05);
|
||||
}
|
||||
.reply-target text:last-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.comment-editor textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 130rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.3);
|
||||
border-radius: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.comment-editor__placeholder {
|
||||
color: #ab9a86;
|
||||
}
|
||||
.comment-editor .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-error {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="visibleFiles.length"
|
||||
class="feed-media"
|
||||
:class="`feed-media--${layout}`"
|
||||
>
|
||||
<image
|
||||
v-for="file in visibleFiles"
|
||||
:key="file.fileId || file.ossId || file.accessUrl"
|
||||
class="feed-media__image"
|
||||
:src="file.accessUrl"
|
||||
mode="aspectFill"
|
||||
role="button"
|
||||
aria-label="查看动态图片"
|
||||
@click.stop="preview(file)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
files: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const visibleFiles = computed(() =>
|
||||
props.files.filter((file) => typeof file?.accessUrl === "string" && file.accessUrl),
|
||||
);
|
||||
const layout = computed(() => {
|
||||
if (visibleFiles.value.length === 1) return "single";
|
||||
if (visibleFiles.value.length === 2) return "double";
|
||||
return "grid";
|
||||
});
|
||||
const preview = (file) => {
|
||||
const urls = visibleFiles.value.map((item) => item.accessUrl);
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.feed-media {
|
||||
display: grid;
|
||||
gap: 8rpx;
|
||||
margin-top: 16rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
.feed-media--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.feed-media--double {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.feed-media--grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.feed-media__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 190rpx;
|
||||
background: rgba(120, 84, 48, 0.08);
|
||||
}
|
||||
.feed-media--single .feed-media__image {
|
||||
height: 360rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<view v-if="visible" class="add-dialog-layer" @click="emit('close')">
|
||||
<view class="add-dialog" @click.stop>
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__body">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
<text v-if="creationQuota" class="dialog-copy">
|
||||
{{ creationQuota.createRemaining === -1
|
||||
? "当前可继续创建家谱"
|
||||
: `还可创建 ${creationQuota.createRemaining} 部家谱` }}
|
||||
</text>
|
||||
<view
|
||||
class="add-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image
|
||||
class="add-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-dialog__actions">
|
||||
<AppButton block label="搜索家谱" @click="emit('search')" />
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="继续创建家谱"
|
||||
:disabled="creationQuota?.canCreate === false"
|
||||
@click="emit('create')"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
|
||||
defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
creationQuota: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "search", "create"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.add-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(34, 20, 12, 0.68);
|
||||
}
|
||||
|
||||
.add-dialog {
|
||||
@include adaptive.adaptive-genealogy-add-sheet;
|
||||
width: 100%;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
}
|
||||
|
||||
.add-dialog__content {
|
||||
display: flex;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
padding: 96rpx 52rpx calc(96rpx + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-dialog__body {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.add-dialog__heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96rpx;
|
||||
}
|
||||
|
||||
.add-dialog .dialog-title,
|
||||
.add-dialog .dialog-copy {
|
||||
display: block;
|
||||
grid-column: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.dialog-copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.add-dialog__close {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -22rpx;
|
||||
}
|
||||
|
||||
.add-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.add-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 62rpx -32rpx 0;
|
||||
}
|
||||
|
||||
.add-dialog__actions > .app-button {
|
||||
width: 595rpx;
|
||||
max-width: 100%;
|
||||
min-height: 96rpx;
|
||||
}
|
||||
|
||||
.add-dialog__actions > .app-button + .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:G01 已创建/已加入家谱的题签列表项;保持透明题签框叠在纸纹底图上。 -->
|
||||
<template>
|
||||
<view
|
||||
class="genealogy-card"
|
||||
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="managerVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="家谱邀请"
|
||||
title="邀请家人加入"
|
||||
confirm-text="生成新邀请码"
|
||||
cancel-text="关闭"
|
||||
show-cancel
|
||||
@confirm="requestIssueInvitation"
|
||||
@cancel="closeManager"
|
||||
>
|
||||
<text class="invitation-manager__note">
|
||||
邀请码只会显示一次,请及时发送给家人;有效期和使用状态以页面提示为准。
|
||||
</text>
|
||||
<AppLoading v-if="invitationState === 'loading'" text="正在加载邀请记录" />
|
||||
<view v-else-if="invitationState === 'error'" class="invitation-manager__state">
|
||||
<text>{{ invitationError || "邀请记录暂时无法加载。" }}</text>
|
||||
<AppButton compact type="secondary" label="重新加载" @click="loadInvitations" />
|
||||
</view>
|
||||
<view v-else-if="invitations.length" class="invitation-manager__list">
|
||||
<view
|
||||
v-for="invitation in invitations"
|
||||
:key="invitation.id"
|
||||
class="invitation-manager__row"
|
||||
>
|
||||
<view>
|
||||
<text>{{ invitation.genealogyName }}</text>
|
||||
<text>有效至 {{ formatInvitationTime(invitation.expiresAt) }}</text>
|
||||
<text>{{ invitationStatusLabel(invitation.status) }}</text>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="invitation.status === 'ACTIVE'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="撤销"
|
||||
:disabled="revokingInvitationId === invitation.id"
|
||||
@click="requestRevokeInvitation(invitation)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="invitation-manager__state">
|
||||
<text>还没有发出过邀请码。</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="issueConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="生成邀请码"
|
||||
title="生成新的邀请码?"
|
||||
message="生成后请只发送给要邀请的家人。邀请码是否有效,以页面提示为准。"
|
||||
:confirm-text="issuingInvitation ? '正在生成' : '确认生成'"
|
||||
cancel-text="暂不生成"
|
||||
show-cancel
|
||||
@confirm="issueInvitation"
|
||||
@cancel="closeIssueConfirmation"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="issuedInvitationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="邀请码已生成"
|
||||
title="请立即保存并发送"
|
||||
:message="issuedInvitationMessage"
|
||||
confirm-text="我已保存"
|
||||
:show-cancel="false"
|
||||
@confirm="closeIssuedInvitation"
|
||||
@cancel="closeIssuedInvitation"
|
||||
>
|
||||
<text v-if="issuedInvitation" class="issued-invitation__token" selectable>
|
||||
{{ issuedInvitation.token }}
|
||||
</text>
|
||||
<text v-if="copyNotice" class="issued-invitation__notice">{{ copyNotice }}</text>
|
||||
<AppButton
|
||||
v-if="issuedInvitation"
|
||||
block
|
||||
type="secondary"
|
||||
label="复制邀请码"
|
||||
@click="copyIssuedInvitation"
|
||||
/>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="revokeConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="撤销确认"
|
||||
title="撤销这个邀请码?"
|
||||
:message="revokeConfirmationMessage"
|
||||
:confirm-text="revokingInvitationId ? '正在撤销' : '确认撤销'"
|
||||
cancel-text="暂不撤销"
|
||||
show-cancel
|
||||
@confirm="revokeInvitation"
|
||||
@cancel="closeRevokeConfirmation"
|
||||
/>
|
||||
</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 { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
|
||||
const props = defineProps({
|
||||
genealogyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
|
||||
const managerVisible = ref(false);
|
||||
const invitationState = ref("idle");
|
||||
const invitationError = ref("");
|
||||
const invitations = ref([]);
|
||||
const issueConfirmationVisible = ref(false);
|
||||
const issuingInvitation = ref(false);
|
||||
const issuedInvitationVisible = ref(false);
|
||||
const issuedInvitation = ref(null);
|
||||
const copyNotice = ref("");
|
||||
const revokeConfirmationVisible = ref(false);
|
||||
const revokeTarget = ref(null);
|
||||
const revokingInvitationId = ref("");
|
||||
const invitationListRequestController = createRequestController();
|
||||
const invitationIssuanceRequestController = createRequestController();
|
||||
const invitationRevocationRequestController = createRequestController();
|
||||
const invitationIssuanceGuard = createNonIdempotentWriteGuard();
|
||||
let componentActive = true;
|
||||
|
||||
const isBusy = computed(() =>
|
||||
issuingInvitation.value || Boolean(revokingInvitationId.value),
|
||||
);
|
||||
const hasTransient = computed(() =>
|
||||
managerVisible.value ||
|
||||
issueConfirmationVisible.value ||
|
||||
issuedInvitationVisible.value ||
|
||||
revokeConfirmationVisible.value,
|
||||
);
|
||||
const issuedInvitationMessage = computed(() =>
|
||||
issuedInvitation.value
|
||||
? `适用于「${issuedInvitation.value.genealogyName}」,有效至 ${formatInvitationTime(
|
||||
issuedInvitation.value.expiresAt,
|
||||
)}。关闭后无法再次查看原始邀请码。`
|
||||
: "",
|
||||
);
|
||||
const revokeConfirmationMessage = computed(() =>
|
||||
revokeTarget.value
|
||||
? `撤销后「${revokeTarget.value.genealogyName}」的邀请码将不能再被使用。`
|
||||
: "",
|
||||
);
|
||||
|
||||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||||
watch(hasTransient, (visible) => emit("transient-change", visible), {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const formatInvitationTime = (value) =>
|
||||
typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : value;
|
||||
const invitationStatusLabel = (status) =>
|
||||
({
|
||||
ACTIVE: "等待使用",
|
||||
REDEEMED: "已被使用",
|
||||
REVOKED: "已撤销",
|
||||
EXPIRED: "已过期",
|
||||
})[status] || "状态已更新";
|
||||
|
||||
const loadInvitations = async () => {
|
||||
if (!props.genealogyId) return;
|
||||
invitationListRequestController.abort();
|
||||
invitationState.value = "loading";
|
||||
invitationError.value = "";
|
||||
try {
|
||||
const invitationRows = await genealogyMembershipApi.getMyGenealogyInvitations({
|
||||
requestController: invitationListRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
invitations.value = invitationRows.filter(
|
||||
(invitation) => invitation.genealogyId === props.genealogyId,
|
||||
);
|
||||
invitationState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
invitationState.value = "error";
|
||||
invitationError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"邀请记录加载失败,请稍后重试。",
|
||||
);
|
||||
}
|
||||
};
|
||||
const open = () => {
|
||||
if (!props.genealogyId) return false;
|
||||
managerVisible.value = true;
|
||||
void loadInvitations();
|
||||
return true;
|
||||
};
|
||||
const closeManager = () => {
|
||||
if (isBusy.value) return false;
|
||||
managerVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const requestIssueInvitation = () => {
|
||||
if (invitationState.value === "loading" || isBusy.value) return;
|
||||
issueConfirmationVisible.value = true;
|
||||
};
|
||||
const closeIssueConfirmation = () => {
|
||||
if (isBusy.value) return false;
|
||||
issueConfirmationVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const issueInvitation = async () => {
|
||||
if (!props.genealogyId || isBusy.value) return;
|
||||
const issueAttempt = invitationIssuanceGuard.begin({
|
||||
genealogyId: props.genealogyId,
|
||||
});
|
||||
if (issueAttempt === null) {
|
||||
managerVisible.value = true;
|
||||
invitationError.value =
|
||||
"上次生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
|
||||
return;
|
||||
}
|
||||
issuingInvitation.value = true;
|
||||
issueConfirmationVisible.value = false;
|
||||
managerVisible.value = false;
|
||||
copyNotice.value = "";
|
||||
try {
|
||||
const invitation = await genealogyMembershipApi.issueGenealogyInvitation(
|
||||
props.genealogyId,
|
||||
{ requestController: invitationIssuanceRequestController },
|
||||
);
|
||||
if (!componentActive) return;
|
||||
issuedInvitation.value = invitation;
|
||||
issuedInvitationVisible.value = true;
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
if (invitationIssuanceGuard.recordFailure(issueAttempt, error)) {
|
||||
managerVisible.value = true;
|
||||
invitationError.value =
|
||||
"邀请码生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
invitationError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"邀请码生成失败,请稍后重试。",
|
||||
);
|
||||
invitationState.value = "error";
|
||||
managerVisible.value = true;
|
||||
} finally {
|
||||
if (componentActive) issuingInvitation.value = false;
|
||||
}
|
||||
};
|
||||
const closeIssuedInvitation = () => {
|
||||
if (isBusy.value) return false;
|
||||
issuedInvitationVisible.value = false;
|
||||
issuedInvitation.value = null;
|
||||
copyNotice.value = "";
|
||||
return true;
|
||||
};
|
||||
const copyIssuedInvitation = () => {
|
||||
const invitationToken = issuedInvitation.value?.token;
|
||||
if (!invitationToken) return;
|
||||
if (typeof uni === "undefined" || typeof uni.setClipboardData !== "function") {
|
||||
copyNotice.value = "暂时无法自动复制,请手动保存邀请码。";
|
||||
return;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: invitationToken,
|
||||
success: () => {
|
||||
copyNotice.value = "邀请码已复制,请发送给家人。";
|
||||
},
|
||||
fail: () => {
|
||||
copyNotice.value = "复制失败,请手动保存邀请码。";
|
||||
},
|
||||
});
|
||||
};
|
||||
const requestRevokeInvitation = (invitation) => {
|
||||
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
|
||||
revokeTarget.value = invitation;
|
||||
revokeConfirmationVisible.value = true;
|
||||
};
|
||||
const closeRevokeConfirmation = () => {
|
||||
if (isBusy.value) return false;
|
||||
revokeConfirmationVisible.value = false;
|
||||
revokeTarget.value = null;
|
||||
return true;
|
||||
};
|
||||
const revokeInvitation = async () => {
|
||||
const invitation = revokeTarget.value;
|
||||
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
|
||||
revokingInvitationId.value = invitation.id;
|
||||
invitationError.value = "";
|
||||
try {
|
||||
await genealogyMembershipApi.revokeGenealogyInvitation(invitation.id, {
|
||||
requestController: invitationRevocationRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
revokeConfirmationVisible.value = false;
|
||||
revokeTarget.value = null;
|
||||
await loadInvitations();
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
invitationError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"邀请码撤销失败,请稍后重试。",
|
||||
);
|
||||
revokeConfirmationVisible.value = false;
|
||||
revokeTarget.value = null;
|
||||
} finally {
|
||||
if (componentActive) revokingInvitationId.value = "";
|
||||
}
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (isBusy.value) return true;
|
||||
if (revokeConfirmationVisible.value) return closeRevokeConfirmation();
|
||||
if (issuedInvitationVisible.value) return closeIssuedInvitation();
|
||||
if (issueConfirmationVisible.value) return closeIssueConfirmation();
|
||||
if (managerVisible.value) return closeManager();
|
||||
return false;
|
||||
};
|
||||
|
||||
defineExpose({ closeTransient, open });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
invitationListRequestController.abort();
|
||||
invitationIssuanceRequestController.abort();
|
||||
invitationRevocationRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.invitation-manager__note,
|
||||
.invitation-manager__state text,
|
||||
.invitation-manager__row text,
|
||||
.issued-invitation__token,
|
||||
.issued-invitation__notice {
|
||||
display: block;
|
||||
}
|
||||
.invitation-manager__note,
|
||||
.invitation-manager__state text,
|
||||
.invitation-manager__row text:not(:first-child),
|
||||
.issued-invitation__notice {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.invitation-manager__state,
|
||||
.invitation-manager__list {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.invitation-manager__state .app-button {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.invitation-manager__row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||
text-align: left;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.invitation-manager__row > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.issued-invitation__token {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx dashed rgba(159, 23, 15, 0.48);
|
||||
border-radius: 8rpx;
|
||||
color: $brand-red;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.issued-invitation__notice {
|
||||
margin-top: 12rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.issued-invitation__token + .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<view v-if="visible" class="genealogy-order-layer" @click="requestClose">
|
||||
<view
|
||||
class="genealogy-order-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="调整家谱排序"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-order-dialog__head">
|
||||
<view>
|
||||
<text class="dialog-title">调整家谱排序</text>
|
||||
<text class="genealogy-order-dialog__copy">保存后将按此顺序显示我的家谱</text>
|
||||
</view>
|
||||
<view
|
||||
class="genealogy-order-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭排序"
|
||||
hover-class="action-hover"
|
||||
@click="requestClose"
|
||||
>
|
||||
<image
|
||||
class="genealogy-order-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="genealogy-order-list" scroll-y>
|
||||
<view
|
||||
v-for="(genealogy, index) in orderDraft"
|
||||
:key="genealogy.id"
|
||||
class="genealogy-order-item"
|
||||
>
|
||||
<view class="genealogy-order-item__main">
|
||||
<text class="genealogy-order-item__position">{{ index + 1 }}</text>
|
||||
<view>
|
||||
<text class="genealogy-order-item__name">{{ genealogy.name }}</text>
|
||||
<text class="genealogy-order-item__role">
|
||||
{{ genealogy.canManage ? "管理员" : "成员" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="genealogy-order-item__actions">
|
||||
<button
|
||||
class="genealogy-order-item__move"
|
||||
:disabled="saving || index === 0"
|
||||
@click="moveOrderItem(index, -1)"
|
||||
>上移</button>
|
||||
<button
|
||||
class="genealogy-order-item__move"
|
||||
:disabled="saving || index === orderDraft.length - 1"
|
||||
@click="moveOrderItem(index, 1)"
|
||||
>下移</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text v-if="orderError" class="genealogy-order-error">{{ orderError }}</text>
|
||||
<view class="genealogy-order-dialog__actions">
|
||||
<AppButton type="secondary" :disabled="saving" label="取消" @click="requestClose" />
|
||||
<AppButton
|
||||
:disabled="saving || !isOrderDirty"
|
||||
:label="saving ? '正在保存' : '保存排序'"
|
||||
@click="saveOrder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
genealogies: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "saved"]);
|
||||
const orderDraft = ref([]);
|
||||
const saving = ref(false);
|
||||
const orderError = ref("");
|
||||
const orderSaveRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
|
||||
const isOrderDirty = computed(() =>
|
||||
orderDraft.value.some(
|
||||
(genealogy, index) => genealogy.id !== props.genealogies[index]?.id,
|
||||
),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) return;
|
||||
orderDraft.value = props.genealogies.map((genealogy) => ({ ...genealogy }));
|
||||
orderError.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const requestClose = () => {
|
||||
if (saving.value) return false;
|
||||
orderError.value = "";
|
||||
emit("close");
|
||||
return true;
|
||||
};
|
||||
|
||||
const moveOrderItem = (sourceIndex, direction) => {
|
||||
const targetIndex = sourceIndex + direction;
|
||||
if (
|
||||
saving.value ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= orderDraft.value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextOrder = [...orderDraft.value];
|
||||
[nextOrder[sourceIndex], nextOrder[targetIndex]] = [
|
||||
nextOrder[targetIndex],
|
||||
nextOrder[sourceIndex],
|
||||
];
|
||||
orderDraft.value = nextOrder;
|
||||
orderError.value = "";
|
||||
};
|
||||
|
||||
const saveOrder = async () => {
|
||||
if (saving.value || !isOrderDirty.value) return;
|
||||
const draftIds = orderDraft.value.map((genealogy) => String(genealogy.id));
|
||||
const currentIds = props.genealogies.map((genealogy) => String(genealogy.id));
|
||||
if (
|
||||
draftIds.length !== currentIds.length ||
|
||||
new Set(draftIds).size !== draftIds.length ||
|
||||
draftIds.some((id) => !currentIds.includes(id))
|
||||
) {
|
||||
orderError.value = "当前家谱列表已变化,请关闭后重新调整。";
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
orderError.value = "";
|
||||
try {
|
||||
const confirmedGenealogies = await genealogyApi.saveMyGenealogyOrder(draftIds, {
|
||||
requestController: orderSaveRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
emit("saved", confirmedGenealogies);
|
||||
} catch (error) {
|
||||
if (componentActive && !isRequestCancelled(error)) {
|
||||
orderError.value = getRequestErrorMessage(error, "保存排序失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (componentActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
orderSaveRequestController.abort();
|
||||
});
|
||||
|
||||
defineExpose({ requestClose });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.genealogy-order-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(36, 24, 16, 0.54);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog {
|
||||
width: 100%;
|
||||
max-width: 680rpx;
|
||||
max-height: 80vh;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid rgba(128, 78, 29, 0.28);
|
||||
border-radius: 24rpx;
|
||||
background: #f9f6ef;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__copy,
|
||||
.genealogy-order-item__name,
|
||||
.genealogy-order-item__role,
|
||||
.genealogy-order-error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__copy,
|
||||
.genealogy-order-item__role {
|
||||
margin-top: 8rpx;
|
||||
color: #8a7564;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__close {
|
||||
display: flex;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-list {
|
||||
max-height: 720rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
min-height: 108rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid rgba(149, 103, 49, 0.16);
|
||||
}
|
||||
|
||||
.genealogy-order-item__main,
|
||||
.genealogy-order-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.genealogy-order-item__main {
|
||||
min-width: 0;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__main > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.genealogy-order-item__position {
|
||||
display: grid;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #7b4024;
|
||||
color: #fffaf0;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-item__name {
|
||||
color: #3e2b20;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.genealogy-order-item__actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move {
|
||||
min-width: 82rpx;
|
||||
height: 54rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(123, 64, 36, 0.46);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: #7b4024;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 52rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move[disabled] {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.genealogy-order-error {
|
||||
margin-top: 20rpx;
|
||||
color: #b3432f;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 18rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__actions .app-button {
|
||||
width: 220rpx;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<RegionPickerSheet
|
||||
:visible="visible"
|
||||
:columns="columns"
|
||||
:indexes="indexes"
|
||||
:indicator-style="indicatorStyle"
|
||||
:close-on-mask="closeOnMask"
|
||||
@change="changeSelection"
|
||||
@cancel="close"
|
||||
@confirm="confirmSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import RegionPickerSheet from "@/components/genealogy/RegionPickerSheet.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { regionApi } from "@/services/api/region-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
closeOnMask: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxLevels: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
validator: (levelCount) => Number.isInteger(levelCount) && levelCount > 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"error-change",
|
||||
"loading-change",
|
||||
"select",
|
||||
"transient-change",
|
||||
]);
|
||||
|
||||
const indicatorStyle =
|
||||
"height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
const visible = ref(false);
|
||||
const columns = ref([]);
|
||||
const indexes = ref([0]);
|
||||
const selectedTrail = ref([]);
|
||||
const preparedRegionCode = ref("");
|
||||
const regionRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
let loading = false;
|
||||
|
||||
const setLoading = (nextLoading) => {
|
||||
loading = nextLoading;
|
||||
emit("loading-change", nextLoading);
|
||||
};
|
||||
const setError = (message = "") => {
|
||||
emit("error-change", message);
|
||||
};
|
||||
const fetchRegionChildren = (parentCode) =>
|
||||
regionApi.getRegionChildren(parentCode, {
|
||||
requestController: regionRequestController,
|
||||
});
|
||||
|
||||
const loadColumns = async (requestedIndexes = []) => {
|
||||
const rootOptions = columns.value[0] || (await fetchRegionChildren("0"));
|
||||
if (!rootOptions.length) return false;
|
||||
const nextColumns = [rootOptions];
|
||||
const nextIndexes = [];
|
||||
let options = rootOptions;
|
||||
for (let level = 0; level < props.maxLevels; level += 1) {
|
||||
const requestedIndex = Number(requestedIndexes[level]);
|
||||
const selectedIndex = Number.isInteger(requestedIndex)
|
||||
? Math.min(Math.max(requestedIndex, 0), options.length - 1)
|
||||
: 0;
|
||||
const selectedOption = options[selectedIndex];
|
||||
if (!selectedOption) break;
|
||||
nextIndexes.push(selectedIndex);
|
||||
if (level === props.maxLevels - 1) break;
|
||||
const childOptions = await fetchRegionChildren(selectedOption.regionCode);
|
||||
if (!childOptions.length) break;
|
||||
nextColumns.push(childOptions);
|
||||
options = childOptions;
|
||||
}
|
||||
if (!componentActive) return false;
|
||||
columns.value = nextColumns;
|
||||
indexes.value = nextIndexes;
|
||||
selectedTrail.value = nextColumns
|
||||
.map((column, level) => column[nextIndexes[level]])
|
||||
.filter(Boolean);
|
||||
return Boolean(selectedTrail.value.length);
|
||||
};
|
||||
|
||||
const loadRegionPath = async (regionCode) => {
|
||||
const regionPath = (await regionApi.getRegionPath(regionCode, {
|
||||
requestController: regionRequestController,
|
||||
})).filter((regionNode) => regionNode?.regionCode);
|
||||
if (
|
||||
!regionPath.length ||
|
||||
regionPath[regionPath.length - 1].regionCode !== regionCode
|
||||
) {
|
||||
throw new Error("REGION_PATH_MISMATCH");
|
||||
}
|
||||
const nextColumns = [];
|
||||
const nextIndexes = [];
|
||||
const nextTrail = [];
|
||||
let parentCode = "0";
|
||||
for (const regionPathNode of regionPath.slice(0, props.maxLevels)) {
|
||||
const options = await fetchRegionChildren(parentCode);
|
||||
const selectedIndex = options.findIndex(
|
||||
(option) => option.regionCode === regionPathNode.regionCode,
|
||||
);
|
||||
if (selectedIndex < 0) throw new Error("REGION_PATH_MISMATCH");
|
||||
nextColumns.push(options);
|
||||
nextIndexes.push(selectedIndex);
|
||||
nextTrail.push(options[selectedIndex]);
|
||||
parentCode = regionPathNode.regionCode;
|
||||
}
|
||||
if (!componentActive) return false;
|
||||
columns.value = nextColumns;
|
||||
indexes.value = nextIndexes;
|
||||
selectedTrail.value = nextTrail;
|
||||
return Boolean(nextTrail.length);
|
||||
};
|
||||
|
||||
const prepare = async (initialRegionCode = "") => {
|
||||
const normalizedRegionCode = String(initialRegionCode || "");
|
||||
if (loading) return false;
|
||||
if (
|
||||
columns.value.length &&
|
||||
preparedRegionCode.value === normalizedRegionCode
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
regionRequestController.abort();
|
||||
setLoading(true);
|
||||
setError();
|
||||
try {
|
||||
const ready = normalizedRegionCode
|
||||
? await loadRegionPath(normalizedRegionCode)
|
||||
: await loadColumns([0]);
|
||||
if (!componentActive || !ready) return false;
|
||||
preparedRegionCode.value = normalizedRegionCode;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return false;
|
||||
const fallback = normalizedRegionCode
|
||||
? "暂时无法定位当前地区,请稍后重试。"
|
||||
: "地区列表加载失败,请重试";
|
||||
setError(getRequestErrorMessage(error, fallback));
|
||||
return false;
|
||||
} finally {
|
||||
if (componentActive) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const open = async (initialRegionCode = "") => {
|
||||
if (!(await prepare(initialRegionCode))) return false;
|
||||
visible.value = true;
|
||||
emit("transient-change", true);
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
visible.value = false;
|
||||
emit("transient-change", false);
|
||||
};
|
||||
const changeSelection = async (event) => {
|
||||
if (loading) return;
|
||||
const requestedIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const changedLevel = requestedIndexes.findIndex(
|
||||
(index, level) => index !== (indexes.value[level] || 0),
|
||||
);
|
||||
if (changedLevel < 0) return;
|
||||
setLoading(true);
|
||||
setError();
|
||||
try {
|
||||
const loaded = await loadColumns(requestedIndexes.slice(0, changedLevel + 1));
|
||||
if (!componentActive || !loaded) return;
|
||||
preparedRegionCode.value = "";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
setError(getRequestErrorMessage(error, "地区列表加载失败,请重试"));
|
||||
} finally {
|
||||
if (componentActive) setLoading(false);
|
||||
}
|
||||
};
|
||||
const confirmSelection = () => {
|
||||
const trail = columns.value
|
||||
.map((column, level) => column[Number(indexes.value[level])])
|
||||
.filter(Boolean);
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
selectedTrail.value = trail;
|
||||
preparedRegionCode.value = region.regionCode;
|
||||
emit("select", { region, trail });
|
||||
close();
|
||||
};
|
||||
|
||||
defineExpose({ close, open, prepare });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
regionRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<view v-if="visible" class="region-sheet">
|
||||
<view class="region-sheet__mask" @click="requestMaskClose" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro">
|
||||
<text class="region-sheet__title">选择地区</text>
|
||||
</view>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text
|
||||
v-for="label in columnLabels"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>
|
||||
{{ label }}
|
||||
</text>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="indicatorStyle"
|
||||
:value="indexes"
|
||||
@change="$emit('change', $event)"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
indexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>
|
||||
{{ option.label }}
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="$emit('cancel')">取消</view>
|
||||
<button class="region-sheet__confirm" @click="$emit('confirm')">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
columns: { type: Array, default: () => [] },
|
||||
indexes: { type: Array, default: () => [] },
|
||||
indicatorStyle: { type: String, required: true },
|
||||
closeOnMask: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["change", "cancel", "confirm"]);
|
||||
|
||||
const regionLevelLabels = Object.freeze([
|
||||
"省份",
|
||||
"城市",
|
||||
"区县",
|
||||
"乡镇街道",
|
||||
"村社区",
|
||||
]);
|
||||
const columnLabels = computed(() =>
|
||||
regionLevelLabels.slice(0, props.columns.length),
|
||||
);
|
||||
|
||||
const requestMaskClose = () => {
|
||||
if (props.closeOnMask) emit("cancel");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.region-sheet {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.region-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(43, 30, 20, 0.42);
|
||||
}
|
||||
.region-sheet__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 30rpx 30rpx 0 0;
|
||||
background: #fdf9ef;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
|
||||
}
|
||||
.region-sheet__intro {
|
||||
padding: 0 10rpx 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__picker {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 252, 245, 0.8);
|
||||
}
|
||||
.region-sheet__column-headings {
|
||||
display: flex;
|
||||
height: 84rpx;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.region-sheet__column-heading {
|
||||
box-sizing: border-box;
|
||||
width: 33.333%;
|
||||
padding: 24rpx 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__column-heading + .region-sheet__column-heading {
|
||||
border-left: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.region-sheet__picker-view {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
}
|
||||
.region-sheet__picker-item {
|
||||
box-sizing: border-box;
|
||||
height: 104rpx;
|
||||
overflow: hidden;
|
||||
padding: 0 6rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 104rpx;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.region-sheet__picker-item--selected {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 22rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.region-sheet__cancel {
|
||||
min-width: 116rpx;
|
||||
padding: 20rpx 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__confirm {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view v-if="visible" class="genealogy-switcher-layer" @click="emit('close')">
|
||||
<view
|
||||
class="genealogy-switcher"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="切换当前家谱"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-switcher__content">
|
||||
<text class="dialog-title">切换当前家谱</text>
|
||||
<view
|
||||
class="genealogy-switcher__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image
|
||||
class="genealogy-switcher__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<scroll-view class="genealogy-switcher__list" scroll-y>
|
||||
<button
|
||||
v-for="genealogy in genealogies"
|
||||
:key="genealogy.id"
|
||||
class="switcher-item"
|
||||
:class="{ 'switcher-item--active': genealogy.id === selectedGenealogyId }"
|
||||
:aria-pressed="genealogy.id === selectedGenealogyId"
|
||||
:aria-label="`${genealogy.name},${genealogy.location},${genealogy.memberCount} 位成员`"
|
||||
@click="emit('select', genealogy)"
|
||||
>
|
||||
<view>
|
||||
<text class="switcher-item__name">{{ genealogy.name }}</text>
|
||||
<text class="switcher-item__meta">
|
||||
{{ genealogy.location }} · {{ genealogy.memberCount }} 位成员
|
||||
</text>
|
||||
</view>
|
||||
<text class="switcher-item__state">
|
||||
{{ genealogy.id === selectedGenealogyId ? "当前" : "选择" }}
|
||||
</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
genealogies: { type: Array, required: true },
|
||||
selectedGenealogyId: { type: [String, Number], default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "select"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.genealogy-switcher-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx;
|
||||
background: rgba(34, 20, 12, 0.58);
|
||||
}
|
||||
|
||||
.genealogy-switcher {
|
||||
@include adaptive.adaptive-genealogy-switcher;
|
||||
width: 670rpx;
|
||||
max-width: 100%;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
}
|
||||
|
||||
.genealogy-switcher__content {
|
||||
display: grid;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 120rpx 58rpx 140rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__content > .dialog-title {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__close {
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__list {
|
||||
grid-area: 2 / 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 456rpx);
|
||||
margin-top: 24rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.switcher-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 112rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 18rpx 16rpx;
|
||||
border: 1rpx solid transparent;
|
||||
border-bottom-color: rgba(181, 138, 75, 0.42);
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.switcher-item::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.switcher-item__name,
|
||||
.switcher-item__meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.switcher-item__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.switcher-item__meta {
|
||||
margin-top: 6rpx;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.switcher-item__state {
|
||||
color: $brand-red;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.switcher-item--active {
|
||||
border-color: rgba(159, 23, 15, 0.22);
|
||||
background: rgba(159, 23, 15, 0.055);
|
||||
}
|
||||
|
||||
.switcher-item--active .switcher-item__name {
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="formVisible"
|
||||
eyebrow="提现申请"
|
||||
title="填写收款信息"
|
||||
message="提交后将冻结对应收益,审核通过后按收款码转账。"
|
||||
confirm-text="核对并提交"
|
||||
cancel-text="暂不提现"
|
||||
show-cancel
|
||||
:close-on-mask="!submitting && !uploading"
|
||||
@confirm="reviewWithdrawal"
|
||||
@cancel="close"
|
||||
>
|
||||
<view class="withdrawal-form">
|
||||
<text>提现金额(元)</text>
|
||||
<input
|
||||
v-model.trim="withdrawalForm.amount"
|
||||
type="digit"
|
||||
maxlength="19"
|
||||
placeholder="请输入提现金额"
|
||||
/>
|
||||
<text>收款人姓名</text>
|
||||
<input
|
||||
v-model.trim="withdrawalForm.payoutAccountName"
|
||||
maxlength="64"
|
||||
placeholder="请输入收款码对应姓名"
|
||||
/>
|
||||
<view
|
||||
class="qr-picker"
|
||||
role="button"
|
||||
aria-label="选择收款码图片"
|
||||
@click="choosePayoutQr"
|
||||
>
|
||||
<image
|
||||
v-if="withdrawalForm.qrPreview"
|
||||
:src="withdrawalForm.qrPreview"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ payoutQrPrompt }}</text>
|
||||
</view>
|
||||
<text v-if="withdrawalError" class="form-error">{{ withdrawalError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="confirmationVisible"
|
||||
title="确认申请提现吗?"
|
||||
:message="confirmationMessage"
|
||||
:confirm-text="submitting ? '正在提交' : '确认提交'"
|
||||
cancel-text="返回修改"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="submitWithdrawal"
|
||||
@cancel="confirmationVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { earningApi } from "@/services/api/earning-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { parseMoneyToCents } from "@/utils/profile/earning-money.js";
|
||||
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
|
||||
|
||||
const props = defineProps({
|
||||
availableAmount: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
minimumWithdrawal: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
afterSubmitted: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["busy-change", "transient-change"]);
|
||||
|
||||
const formVisible = ref(false);
|
||||
const confirmationVisible = ref(false);
|
||||
const withdrawalError = ref("");
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const withdrawalForm = reactive({
|
||||
requestId: "",
|
||||
amount: "",
|
||||
payoutAccountName: "",
|
||||
payoutQrOssId: null,
|
||||
qrPreview: "",
|
||||
});
|
||||
const payoutQrUploadRequestController = createRequestController();
|
||||
const withdrawalSubmissionRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
|
||||
const isBusy = computed(() => uploading.value || submitting.value);
|
||||
const hasTransient = computed(() =>
|
||||
formVisible.value || confirmationVisible.value,
|
||||
);
|
||||
const payoutQrPrompt = computed(() => {
|
||||
if (uploading.value) return "正在上传收款码…";
|
||||
return withdrawalForm.payoutQrOssId
|
||||
? "重新选择收款码"
|
||||
: "选择微信或支付宝收款码";
|
||||
});
|
||||
const confirmationMessage = computed(() =>
|
||||
`本次申请 ¥${withdrawalForm.amount || "0.00"},收款人 ${
|
||||
withdrawalForm.payoutAccountName || "未填写"
|
||||
}。请确认金额和收款码无误。`,
|
||||
);
|
||||
|
||||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||||
watch(hasTransient, (visible) => emit("transient-change", visible), {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const createWithdrawalRequestId = () =>
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `withdrawal-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const validateWithdrawal = () => {
|
||||
const amountInCents = parseMoneyToCents(withdrawalForm.amount);
|
||||
const availableInCents = parseMoneyToCents(props.availableAmount);
|
||||
const minimumInCents = parseMoneyToCents(props.minimumWithdrawal || "0.01");
|
||||
if (amountInCents === null || amountInCents <= 0n) {
|
||||
return "请输入正确的提现金额,最多保留两位小数。";
|
||||
}
|
||||
if (minimumInCents !== null && amountInCents < minimumInCents) {
|
||||
return `提现金额不能低于 ¥${props.minimumWithdrawal || "0.01"}。`;
|
||||
}
|
||||
if (availableInCents !== null && amountInCents > availableInCents) {
|
||||
return "提现金额不能超过当前可用收益。";
|
||||
}
|
||||
if (!withdrawalForm.payoutAccountName.trim()) return "请填写收款人姓名。";
|
||||
if (!withdrawalForm.payoutQrOssId) return "请选择收款码图片。";
|
||||
return "";
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (isBusy.value) return false;
|
||||
Object.assign(withdrawalForm, {
|
||||
requestId: createWithdrawalRequestId(),
|
||||
amount: props.minimumWithdrawal || "",
|
||||
payoutAccountName: "",
|
||||
payoutQrOssId: null,
|
||||
qrPreview: "",
|
||||
});
|
||||
withdrawalError.value = "";
|
||||
formVisible.value = true;
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
if (isBusy.value) return false;
|
||||
formVisible.value = false;
|
||||
confirmationVisible.value = false;
|
||||
return true;
|
||||
};
|
||||
const closeTransient = () => {
|
||||
if (confirmationVisible.value && !isBusy.value) {
|
||||
confirmationVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return formVisible.value ? close() : false;
|
||||
};
|
||||
const choosePayoutQr = async () => {
|
||||
if (isBusy.value) return;
|
||||
uploading.value = true;
|
||||
withdrawalError.value = "";
|
||||
try {
|
||||
const payoutQrUpload = await pickAndUploadImage({
|
||||
requestController: payoutQrUploadRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
withdrawalForm.payoutQrOssId = payoutQrUpload.ossId;
|
||||
withdrawalForm.qrPreview =
|
||||
payoutQrUpload.thumbnailUrl || payoutQrUpload.url;
|
||||
} catch (error) {
|
||||
if (
|
||||
componentActive &&
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
withdrawalError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"收款码上传失败,请重新选择。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (componentActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const reviewWithdrawal = () => {
|
||||
if (isBusy.value) return;
|
||||
withdrawalError.value = validateWithdrawal();
|
||||
if (!withdrawalError.value) confirmationVisible.value = true;
|
||||
};
|
||||
const submitWithdrawal = async () => {
|
||||
if (submitting.value) return;
|
||||
withdrawalError.value = validateWithdrawal();
|
||||
if (withdrawalError.value) {
|
||||
confirmationVisible.value = false;
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
let withdrawalCommitted = false;
|
||||
try {
|
||||
await earningApi.requestEarningWithdrawal(
|
||||
{
|
||||
requestId: withdrawalForm.requestId,
|
||||
amount: withdrawalForm.amount,
|
||||
payoutQrOssId: withdrawalForm.payoutQrOssId,
|
||||
payoutAccountName: withdrawalForm.payoutAccountName,
|
||||
},
|
||||
{ requestController: withdrawalSubmissionRequestController },
|
||||
);
|
||||
withdrawalCommitted = true;
|
||||
if (!componentActive) return;
|
||||
confirmationVisible.value = false;
|
||||
formVisible.value = false;
|
||||
await props.afterSubmitted();
|
||||
} catch (error) {
|
||||
if (!componentActive) return;
|
||||
confirmationVisible.value = false;
|
||||
if (withdrawalCommitted) {
|
||||
formVisible.value = false;
|
||||
return;
|
||||
}
|
||||
if (isWriteOutcomeUnknown(error)) {
|
||||
withdrawalError.value =
|
||||
"暂时无法确认是否提交成功,请先关闭窗口查看提现记录,不要重复提交。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
withdrawalError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"提现申请未提交,请核对后重试。",
|
||||
);
|
||||
} finally {
|
||||
if (componentActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ closeTransient, open });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
payoutQrUploadRequestController.abort();
|
||||
withdrawalSubmissionRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.withdrawal-form {
|
||||
width: 100%;
|
||||
margin-top: 14rpx;
|
||||
text-align: left;
|
||||
}
|
||||
.withdrawal-form > text {
|
||||
display: block;
|
||||
margin: 14rpx 0 7rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.withdrawal-form input {
|
||||
box-sizing: border-box;
|
||||
min-height: 76rpx;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.25);
|
||||
background: #fffdf7;
|
||||
color: $ink;
|
||||
}
|
||||
.qr-picker {
|
||||
display: flex;
|
||||
min-height: 90rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 12rpx;
|
||||
border: 1rpx dashed rgba(159, 35, 35, 0.45);
|
||||
color: $brand-red;
|
||||
text-align: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.qr-picker image {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
.form-error {
|
||||
color: $brand-red !important;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<view v-if="visible" class="member-action-panel-layer" @click="$emit('close')">
|
||||
<view
|
||||
class="member-action-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="member ? `管理${member.name}` : '管理人物'"
|
||||
@click.stop
|
||||
>
|
||||
<image
|
||||
class="member-action-panel__paper"
|
||||
src="/static/assets/modules/tree/transparent/action-panel-paper.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="member-action-panel__head">
|
||||
<view
|
||||
v-if="member"
|
||||
class="member-action-profile"
|
||||
role="button"
|
||||
:aria-label="`查看${member.name}的资料`"
|
||||
@click="$emit('view-profile')"
|
||||
>
|
||||
<view class="member-action-profile__avatar">
|
||||
<AppAvatar :sex="member.sex" />
|
||||
</view>
|
||||
<view class="member-action-profile__copy">
|
||||
<text>{{ member.name }}</text>
|
||||
<text>第 {{ member.generation }} 世 · {{ member.relation }}</text>
|
||||
<text>点击查看人物资料</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="member-action-panel__close-button"
|
||||
role="button"
|
||||
aria-label="关闭人物操作"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<image
|
||||
class="member-action-panel__close"
|
||||
src="/static/assets/modules/tree/transparent/action-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="member-action-panel__content" scroll-y>
|
||||
<view class="member-action-panel__scroll-body">
|
||||
<image
|
||||
class="member-action-panel__relationship-divider"
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="member-action-map">
|
||||
<image
|
||||
class="member-action-map__graph"
|
||||
src="/static/assets/modules/tree/transparent/relation-map.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view
|
||||
v-for="action in relationActions"
|
||||
:key="action.key"
|
||||
class="member-action-map__item"
|
||||
:class="`member-action-map__item--${action.slot}`"
|
||||
role="button"
|
||||
:aria-label="action.label"
|
||||
@click="$emit('select-action', action)"
|
||||
>
|
||||
<image
|
||||
class="member-action-map__item-frame"
|
||||
src="/static/assets/modules/tree/transparent/relation-button-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<image
|
||||
src="/static/assets/modules/tree/transparent/relation-marker.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ action.shortLabel }}</text>
|
||||
<image
|
||||
class="member-action-map__arrow"
|
||||
src="/static/assets/modules/tree/transparent/action-arrow.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-action-grid">
|
||||
<view
|
||||
v-for="action in managementActions"
|
||||
:key="action.key"
|
||||
class="member-action-grid__item"
|
||||
role="button"
|
||||
:aria-label="action.label"
|
||||
@click="$emit('select-action', action)"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/modules/tree/transparent/management-marker.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ action.label }}</text>
|
||||
<image
|
||||
class="member-action-grid__arrow"
|
||||
src="/static/assets/modules/tree/transparent/action-arrow.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<text class="member-action-panel__hint">所有操作都将作用于当前人物</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
member: { type: Object, default: null },
|
||||
relationActions: { type: Array, default: () => [] },
|
||||
managementActions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
defineEmits(["close", "view-profile", "select-action"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-action-panel-layer {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: calc(24rpx + env(safe-area-inset-top)) 0 0;
|
||||
background: rgba(35, 18, 10, 0.62);
|
||||
}
|
||||
.member-action-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 900rpx;
|
||||
min-height: 0;
|
||||
max-height: calc(
|
||||
var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) -
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
flex-direction: column;
|
||||
border-radius: 34rpx 34rpx 0 0;
|
||||
background: #f8edda;
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-action-panel__paper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-action-panel__head {
|
||||
position: absolute;
|
||||
top: 52rpx;
|
||||
right: 36rpx;
|
||||
left: 36rpx;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
.member-action-panel__close-button {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 0;
|
||||
display: flex;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex: 0 0 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.member-action-panel__close {
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
}
|
||||
.member-action-panel__content {
|
||||
z-index: 1;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-action-panel__scroll-body {
|
||||
padding: 160rpx 56rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.member-action-panel__relationship-divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 18rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.member-action-profile {
|
||||
display: flex;
|
||||
width: auto;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-action-profile__avatar {
|
||||
display: block;
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 92rpx;
|
||||
box-sizing: border-box;
|
||||
border: 3rpx solid #d0a65d;
|
||||
border-radius: 50%;
|
||||
background: #fff8e8;
|
||||
box-shadow: 0 3rpx 8rpx rgba(105, 65, 29, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-action-profile__copy {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.member-action-profile__copy text {
|
||||
display: block;
|
||||
}
|
||||
.member-action-profile__copy text:first-child {
|
||||
color: #70261f;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-action-profile__copy text:nth-child(2) {
|
||||
margin-top: 4rpx;
|
||||
color: #74533a;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.member-action-profile__copy text:last-child {
|
||||
display: none;
|
||||
}
|
||||
.member-action-map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 350rpx;
|
||||
}
|
||||
.member-action-map__graph {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-action-map__item {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 190rpx;
|
||||
min-width: 0;
|
||||
min-height: 60rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(177, 126, 64, 0.48);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(250, 236, 211, 0.9);
|
||||
box-shadow: 0 3rpx 7rpx rgba(94, 56, 24, 0.08);
|
||||
}
|
||||
.member-action-map__item-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 3rpx 3rpx rgba(99, 57, 21, 0.12));
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-action-map__item > image:not(.member-action-map__item-frame) {
|
||||
z-index: 1;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
.member-action-map__item .member-action-map__arrow {
|
||||
z-index: 1;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
}
|
||||
.member-action-map__item text {
|
||||
z-index: 1;
|
||||
color: #672820;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-action-map__item--top {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.member-action-map__item--left-top {
|
||||
top: 66rpx;
|
||||
left: 0;
|
||||
}
|
||||
.member-action-map__item--right-top {
|
||||
top: 66rpx;
|
||||
right: 0;
|
||||
}
|
||||
.member-action-map__item--left-bottom {
|
||||
bottom: 106rpx;
|
||||
left: 0;
|
||||
}
|
||||
.member-action-map__item--right-bottom {
|
||||
right: 0;
|
||||
bottom: 106rpx;
|
||||
}
|
||||
.member-action-map__item--bottom {
|
||||
right: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
.member-action-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18rpx;
|
||||
margin-top: 34rpx;
|
||||
padding-top: 30rpx;
|
||||
border-top: 2rpx solid rgba(169, 117, 55, 0.68);
|
||||
}
|
||||
.member-action-grid__item {
|
||||
display: flex;
|
||||
min-height: 96rpx;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding: 0 20rpx;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid rgba(160, 102, 47, 0.62);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(249, 234, 204, 0.92);
|
||||
}
|
||||
.member-action-grid__item image {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
}
|
||||
.member-action-grid__item .member-action-grid__arrow {
|
||||
width: 22rpx;
|
||||
height: 22rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
.member-action-grid__item text {
|
||||
color: #672820;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-action-panel__hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.member-action-profile {
|
||||
gap: 14rpx;
|
||||
padding-right: 14rpx;
|
||||
padding-left: 14rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user