feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user