feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+5 -5
View File
@@ -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;
+2 -2
View File
@@ -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;
+240
View File
@@ -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 -2
View File
@@ -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 } });
+2 -2
View File
@@ -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 -2
View File
@@ -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 数据" });
}
+718
View File
@@ -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>
+68
View File
@@ -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>
+156
View File
@@ -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"
+404
View File
@@ -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>
+339
View File
@@ -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>
+208
View File
@@ -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>
+187
View File
@@ -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>
+189
View File
@@ -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>
+374
View File
@@ -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