719 lines
22 KiB
Vue
719 lines
22 KiB
Vue
<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>
|