565 lines
17 KiB
Vue
565 lines
17 KiB
Vue
<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>
|
||
<view
|
||
v-else
|
||
class="video-card__cover-button video-card__cover-placeholder"
|
||
role="button"
|
||
:aria-label="`播放${video.title}`"
|
||
hover-class="action-hover"
|
||
@click="openVerticalViewer(video)"
|
||
>
|
||
<image
|
||
class="video-card__placeholder-seal"
|
||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||
mode="aspectFit"
|
||
/>
|
||
<view class="video-card__play" aria-hidden="true"></view>
|
||
</view>
|
||
<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__cover-button {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 360rpx;
|
||
border-radius: 10rpx;
|
||
background: #1f1b17;
|
||
overflow: hidden;
|
||
}
|
||
.video-card__cover-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(145deg, #f4e5cd, #d8b77c);
|
||
}
|
||
.video-card__placeholder-seal {
|
||
width: 132rpx;
|
||
height: 152rpx;
|
||
opacity: .72;
|
||
}
|
||
.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>
|