Files
jiapuapp/pages/family/platform-videos.vue
T
2026-09-13 17:45:52 +08:00

565 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>