feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+550
View File
@@ -0,0 +1,550 @@
<template>
<view class="platform-video-page">
<ModulePageBackground module="family" />
<view class="page-header">
<PageHeader title="宣传视频" custom-back @back="requestBack" />
</view>
<view class="page-content">
<view v-if="pageState === 'loading'" class="state-card">
<AppLoading text="正在加载平台视频" />
</view>
<view v-else-if="pageState === 'error'" class="state-card">
<text>{{ pageError }}</text>
<AppButton block label="重新加载" @click="loadPlatformVideos" />
</view>
<view v-else-if="!videos.length" class="state-card">
<text>暂时没有可观看的平台视频</text>
</view>
<view v-else class="video-list">
<AppButton
block
type="secondary"
label="上下滑动观看"
@click="openVerticalViewer(videos[0])"
/>
<view v-for="video in videos" :key="video.id" class="video-card">
<view
v-if="video.coverFile?.accessUrl"
class="video-card__cover-button"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openVerticalViewer(video)"
>
<image
:src="video.coverFile.accessUrl"
mode="aspectFill"
class="video-card__cover"
/>
<view class="video-card__play" aria-hidden="true"></view>
</view>
<video
v-else
:src="video.videoFile.accessUrl"
controls
class="video-card__player"
/>
<text class="video-card__title">{{ video.title }}</text>
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
<text v-if="video.startAt" class="video-card__meta">发布时间{{ video.startAt }}</text>
<view class="video-card__actions">
<AppButton
compact
type="secondary"
:disabled="Boolean(actionKey)"
label="沉浸观看"
@click="openVerticalViewer(video)"
/>
<AppButton
compact
type="secondary"
:disabled="Boolean(actionKey)"
:label="video.likedByCurrentUser ? `已赞 ${video.likeCount}` : `点赞 ${video.likeCount}`"
@click="togglePlatformVideoLike(video)"
/>
<AppButton
compact
type="secondary"
:disabled="Boolean(actionKey)"
:label="`评论 ${video.commentCount}`"
@click="openPlatformVideoComments(video)"
/>
</view>
<text
v-if="actionErrorVideoId === video.id"
class="action-error"
role="alert"
>{{ actionError }}</text>
</view>
</view>
</view>
<AppDialog
:visible="Boolean(commentTarget)"
eyebrow="视频评论"
:title="commentTarget?.title || '平台视频'"
:confirm-text="actionKey === 'send-comment' ? '正在发送' : '发表评论'"
cancel-text="关闭"
show-cancel
:close-on-mask="false"
@confirm="sendPlatformVideoComment"
@cancel="closePlatformVideoComments"
>
<view v-if="comments.length" class="comment-list">
<view v-for="comment in comments" :key="comment.id" class="comment-row">
<text>{{ comment.author }}{{ comment.content }}</text>
<text v-if="comment.time" class="comment-row__time">{{ comment.time }}</text>
<button
v-if="comment.canDelete"
class="comment-row__delete"
:disabled="Boolean(actionKey)"
@click="requestDeletePlatformVideoComment(comment)"
>删除</button>
</view>
</view>
<text v-else class="comments-empty">还没有评论可以先说说你的看法</text>
<text v-if="commentError" class="action-error" role="alert">{{ commentError }}</text>
<textarea
v-model="commentText"
maxlength="1000"
placeholder="说说你的看法"
class="comment-input"
@input="commentError = ''"
/>
</AppDialog>
<VerticalVideoViewer
:visible="verticalViewerVisible"
:videos="videos"
:initial-video-id="verticalViewerInitialId"
title="宣传视频"
:action-busy="Boolean(actionKey)"
@close="closeVerticalViewer"
@like="togglePlatformVideoLike"
@comments="openVerticalViewerComments"
/>
<AppDialog
:visible="Boolean(commentDeleteTarget)"
eyebrow="评论管理"
title="删除这条评论?"
message="删除后将按服务端规则保留占位或移除内容。"
:confirm-text="actionKey === 'delete-comment' ? '正在删除' : '确认删除'"
cancel-text="保留评论"
show-cancel
:close-on-mask="false"
@confirm="deletePlatformVideoComment"
@cancel="closePlatformVideoCommentDelete"
/>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
const placement = ref(PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER);
const requestedVideoId = ref("");
const videos = ref([]);
const verticalViewerVisible = ref(false);
const verticalViewerInitialId = ref("");
const pageState = ref("loading");
const pageError = ref("");
const actionKey = ref("");
const actionErrorVideoId = ref("");
const actionError = ref("");
const commentTarget = ref(null);
const comments = ref([]);
const commentText = ref("");
const commentError = ref("");
const commentDeleteTarget = ref(null);
const platformVideoListController = createRequestController();
const platformVideoLikeController = createRequestController();
const platformVideoCommentListController = createRequestController();
const platformVideoCommentWriteController = createRequestController();
const platformVideoCommentDeleteController = createRequestController();
const platformVideoCommentGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const openVerticalViewer = (video) => {
if (!video || actionKey.value) return;
verticalViewerInitialId.value = String(video.id);
verticalViewerVisible.value = true;
};
const closeVerticalViewer = () => {
verticalViewerVisible.value = false;
};
const openVerticalViewerComments = (video) => {
closeVerticalViewer();
return openPlatformVideoComments(video);
};
const loadPlatformVideos = async () => {
platformVideoListController.abort();
pageState.value = "loading";
pageError.value = "";
try {
const rows = await genealogyCapabilityApi.getPlatformVideos(
placement.value,
{ requestController: platformVideoListController },
);
if (!pageActive) return;
videos.value = rows;
pageState.value = "ready";
if (requestedVideoId.value) {
const requestedVideo = rows.find(
(video) => String(video.id) === requestedVideoId.value,
);
requestedVideoId.value = "";
if (requestedVideo) openVerticalViewer(requestedVideo);
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageError.value = getRequestErrorMessage(error, "平台视频暂时无法读取。");
pageState.value = "error";
}
};
const togglePlatformVideoLike = async (video) => {
if (actionKey.value) return;
const liked = !video.likedByCurrentUser;
actionKey.value = `like-${video.id}`;
actionErrorVideoId.value = "";
actionError.value = "";
try {
await genealogyCapabilityApi.setPlatformVideoLike(
video.id,
liked,
{ requestController: platformVideoLikeController },
);
if (!pageActive) return;
video.likedByCurrentUser = liked;
video.likeCount = Math.max(0, video.likeCount + (liked ? 1 : -1));
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
actionErrorVideoId.value = video.id;
actionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。");
} finally {
if (pageActive) actionKey.value = "";
}
};
const openPlatformVideoComments = async (video) => {
if (actionKey.value) return;
platformVideoCommentListController.abort();
actionKey.value = `comments-${video.id}`;
actionErrorVideoId.value = "";
actionError.value = "";
try {
const rows = await genealogyCapabilityApi.getPlatformVideoComments(
video.id,
{ requestController: platformVideoCommentListController },
);
if (!pageActive) return;
comments.value = rows;
commentTarget.value = video;
commentText.value = "";
commentError.value = "";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
actionErrorVideoId.value = video.id;
actionError.value = getRequestErrorMessage(error, "评论暂时无法读取。");
} finally {
if (pageActive) actionKey.value = "";
}
};
const closePlatformVideoComments = () => {
if (["send-comment", "delete-comment"].includes(actionKey.value)) return;
commentTarget.value = null;
commentDeleteTarget.value = null;
comments.value = [];
commentText.value = "";
commentError.value = "";
};
const sendPlatformVideoComment = async () => {
const commentContent = commentText.value.trim();
if (!commentContent || !commentTarget.value || actionKey.value) return;
const commentPayload = {
videoId: commentTarget.value.id,
commentContent,
};
const commentAttempt = platformVideoCommentGuard.begin(commentPayload);
if (commentAttempt === null) {
commentError.value = "上次评论发送结果待确认,请重新打开评论列表检查,避免重复发布。";
return;
}
actionKey.value = "send-comment";
commentError.value = "";
try {
const createdComment = await genealogyCapabilityApi.createPlatformVideoComment(
commentPayload.videoId,
commentPayload.commentContent,
{ requestController: platformVideoCommentWriteController },
);
if (!pageActive || !commentTarget.value) return;
comments.value.push(createdComment);
commentTarget.value.commentCount += 1;
commentText.value = "";
} catch (error) {
const isOutcomeUnknown = platformVideoCommentGuard.recordFailure(commentAttempt, error);
if (!pageActive || !commentTarget.value) return;
commentError.value = isOutcomeUnknown
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
} finally {
if (pageActive) actionKey.value = "";
}
};
const requestDeletePlatformVideoComment = (comment) => {
if (!comment?.canDelete || actionKey.value) return;
commentError.value = "";
commentDeleteTarget.value = comment;
};
const closePlatformVideoCommentDelete = () => {
if (actionKey.value !== "delete-comment") commentDeleteTarget.value = null;
};
const deletePlatformVideoComment = async () => {
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || actionKey.value) return;
const target = commentDeleteTarget.value;
actionKey.value = "delete-comment";
commentError.value = "";
try {
await genealogyCapabilityApi.deletePlatformVideoComment(
commentTarget.value.id,
target.id,
{ requestController: platformVideoCommentDeleteController },
);
if (!pageActive || !commentTarget.value) return;
comments.value = await genealogyCapabilityApi.getPlatformVideoComments(
commentTarget.value.id,
{ requestController: platformVideoCommentListController },
);
commentTarget.value.commentCount = comments.value.length;
commentDeleteTarget.value = null;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
commentDeleteTarget.value = null;
} finally {
if (pageActive) actionKey.value = "";
}
};
const requestBack = () => {
if (verticalViewerVisible.value) {
closeVerticalViewer();
return true;
}
if (commentDeleteTarget.value) {
closePlatformVideoCommentDelete();
return true;
}
if (commentTarget.value) {
closePlatformVideoComments();
return true;
}
return goBack();
};
onLoad((query) => {
if (Object.values(PLATFORM_VIDEO_PLACEMENT).includes(query?.placement)) {
placement.value = query.placement;
}
requestedVideoId.value = String(query?.videoId || "");
void loadPlatformVideos();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
platformVideoListController.abort();
platformVideoLikeController.abort();
platformVideoCommentListController.abort();
platformVideoCommentWriteController.abort();
platformVideoCommentDeleteController.abort();
});
</script>
<style lang="scss" scoped>
.platform-video-page {
min-height: 100vh;
color: $ink;
}
.page-header,
.page-content {
position: relative;
z-index: 1;
}
.page-content {
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.video-card {
border: 1rpx solid rgba($gold, .38);
border-radius: 16rpx;
background: rgba(255, 252, 245, .92);
}
.state-card {
padding: 48rpx 28rpx;
text-align: center;
}
.state-card text {
display: block;
color: $ink-muted;
}
.state-card .app-button {
margin-top: 20rpx;
}
.video-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.video-card {
padding: 28rpx;
}
.video-card__player {
width: 100%;
height: 360rpx;
border-radius: 10rpx;
background: #1f1b17;
}
.video-card__cover-button {
position: relative;
width: 100%;
height: 360rpx;
border-radius: 10rpx;
background: #1f1b17;
overflow: hidden;
}
.video-card__cover {
width: 100%;
height: 100%;
}
.video-card__play {
position: absolute;
top: 50%;
left: 50%;
display: flex;
width: 76rpx;
height: 76rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, .9);
border-radius: 50%;
background: rgba(31, 27, 23, .64);
transform: translate(-50%, -50%);
}
.video-card__play::after {
width: 0;
height: 0;
margin-left: 6rpx;
border-top: 13rpx solid transparent;
border-bottom: 13rpx solid transparent;
border-left: 20rpx solid #fff;
content: "";
}
.video-card__title,
.video-card__copy,
.video-card__meta,
.comments-empty,
.action-error,
.comment-row text {
display: block;
}
.video-card__title {
margin-top: 16rpx;
font-size: clamp(18px, 30rpx, 22px);
font-weight: 700;
}
.video-card__copy,
.video-card__meta {
margin-top: 10rpx;
color: $ink-muted;
line-height: 1.55;
}
.video-card__meta {
font-size: clamp(12px, 21rpx, 15px);
}
.video-card__actions {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
.comment-list {
width: 100%;
max-height: 440rpx;
overflow-y: auto;
text-align: left;
}
.comment-row {
position: relative;
padding: 14rpx 96rpx 14rpx 0;
border-bottom: 1rpx solid rgba($gold, .2);
color: $ink;
line-height: 1.55;
}
.comment-row__time {
margin-top: 4rpx;
color: $ink-muted;
font-size: clamp(12px, 20rpx, 14px);
}
.comment-row__delete {
position: absolute;
top: 8rpx;
right: 0;
min-height: var(--app-touch-min);
margin: 0;
padding: 0 10rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(12px, 20rpx, 14px);
line-height: var(--app-touch-min);
}
.comment-row__delete::after {
border: 0;
}
.comments-empty {
width: 100%;
margin-top: 18rpx;
color: $ink-muted;
text-align: left;
}
.action-error {
margin-top: 14rpx;
color: $brand-red-dark;
font-size: clamp(13px, 22rpx, 16px);
line-height: 1.5;
}
.comment-input {
width: 100%;
min-height: 120rpx;
margin-top: 18rpx;
padding: 16rpx;
border: 1rpx solid rgba($gold, .38);
border-radius: 10rpx;
box-sizing: border-box;
text-align: left;
}
</style>