1233 lines
39 KiB
Vue
1233 lines
39 KiB
Vue
<template>
|
||
<view class="video-page" :class="`video-state--${pageState}`">
|
||
<ModulePageBackground module="family" />
|
||
<view class="page-header">
|
||
<PageHeader
|
||
title="家族视频"
|
||
:action="pageState === 'list' ? '发布' : ''"
|
||
custom-back
|
||
@back="requestBack"
|
||
@action="openPublishForm"
|
||
/>
|
||
</view>
|
||
<view class="page-content">
|
||
<view v-if="pageState === 'form'" class="video-panel">
|
||
<text class="video-panel__title">{{
|
||
isEdit ? "编辑家族视频" : "发布家族视频"
|
||
}}</text>
|
||
<text class="video-panel__note">{{
|
||
isEdit
|
||
? "原视频、封面和相关设置会保留。"
|
||
: "视频上传成功后会自动关联,无需额外填写。"
|
||
}}</text>
|
||
|
||
<view class="video-field video-field--upload">
|
||
<text class="video-field__label"
|
||
><text class="required-mark">*</text>视频文件</text
|
||
>
|
||
<button
|
||
class="upload-button"
|
||
:disabled="isEdit || uploading || coverUploading || submitting"
|
||
@click="selectVideo"
|
||
>
|
||
{{
|
||
isEdit
|
||
? "当前视频已关联"
|
||
: uploading
|
||
? "上传中…"
|
||
: receipt
|
||
? "重新选择视频"
|
||
: "选择视频"
|
||
}}
|
||
</button>
|
||
<text v-if="receipt" class="upload-receipt"
|
||
>已上传:{{ receipt.fileName || "视频" }}</text
|
||
>
|
||
</view>
|
||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||
|
||
<view class="video-field video-field--upload">
|
||
<text class="video-field__label">视频封面</text>
|
||
<button
|
||
class="upload-button"
|
||
:disabled="uploading || coverUploading || submitting"
|
||
@click="selectCover"
|
||
>
|
||
{{
|
||
coverUploading
|
||
? "上传中…"
|
||
: coverReceipt
|
||
? "重新选择封面"
|
||
: "选择图片"
|
||
}}
|
||
</button>
|
||
<text v-if="coverReceipt" class="upload-receipt"
|
||
>已上传:{{ coverReceipt.fileName || "视频封面" }}</text
|
||
>
|
||
<button v-if="coverReceipt" class="remove-cover-button" :disabled="coverUploading || submitting" @click="clearCover">移除封面</button>
|
||
</view>
|
||
<text v-if="coverUploadError" class="field-error">{{
|
||
coverUploadError
|
||
}}</text>
|
||
|
||
<view class="video-field">
|
||
<text class="video-field__label"
|
||
><text class="required-mark">*</text>视频标题</text
|
||
>
|
||
<input
|
||
v-model="form.videoTitle"
|
||
maxlength="100"
|
||
placeholder="例如:2026 年清明祭祖活动"
|
||
placeholder-class="placeholder"
|
||
@input="submitError = ''"
|
||
/>
|
||
</view>
|
||
<view class="video-field video-field--textarea">
|
||
<text class="video-field__label">视频说明</text>
|
||
<textarea
|
||
v-model="form.videoDesc"
|
||
maxlength="500"
|
||
auto-height
|
||
placeholder="补充视频中的人物、场景或故事"
|
||
placeholder-class="placeholder"
|
||
@input="submitError = ''"
|
||
/>
|
||
</view>
|
||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||
<AppButton
|
||
block
|
||
:disabled="uploading || coverUploading || submitting"
|
||
:label="submitting ? '正在提交…' : isEdit ? '保存视频' : '发布视频'"
|
||
@click="submitVideo"
|
||
/>
|
||
</view>
|
||
|
||
<view v-else-if="pageState === 'form-loading'" class="video-state-card">
|
||
<AppLoading text="正在读取视频详情" />
|
||
</view>
|
||
|
||
<view v-else-if="pageState === 'list'" class="video-list-panel">
|
||
<AppButton block type="secondary" label="观看平台视频" @click="openPlatformVideos" />
|
||
<view v-if="videoListState === 'loading'" class="video-state-card">
|
||
<AppLoading text="正在读取家族视频" />
|
||
</view>
|
||
<view v-else-if="videoListState === 'error'" class="video-state-card">
|
||
<text class="video-state-card__title">暂时无法读取视频</text>
|
||
<text class="video-state-card__copy">请检查网络后重新加载。</text>
|
||
<AppButton
|
||
block
|
||
type="secondary"
|
||
label="重新加载"
|
||
@click="loadVideos"
|
||
/>
|
||
</view>
|
||
<view v-else-if="!videos.length" class="video-state-card">
|
||
<text class="video-state-card__title">还没有家族视频</text>
|
||
<text class="video-state-card__copy"
|
||
>可以先发布一段值得留存的影像。</text
|
||
>
|
||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||
</view>
|
||
<view v-else class="video-card-list">
|
||
<BatchManagementBar
|
||
v-if="deletableVideos.length"
|
||
resource-name="视频"
|
||
:active="videoBatch.selectionMode.value"
|
||
:selected-count="videoBatch.selectedCount.value"
|
||
:all-selected="videoBatch.allSelected.value"
|
||
:busy="videoBatch.deleting.value"
|
||
@start="videoBatch.enterSelectionMode"
|
||
@finish="videoBatch.exitSelectionMode"
|
||
@toggle-all="videoBatch.toggleAll"
|
||
@delete="videoBatch.requestDelete"
|
||
/>
|
||
<text v-if="videoBatch.notice.value" class="batch-notice" role="status">{{ videoBatch.notice.value }}</text>
|
||
<text v-if="videoBatch.error.value" class="field-error" role="alert">{{ videoBatch.error.value }}</text>
|
||
<AppButton
|
||
block
|
||
type="secondary"
|
||
label="上下滑动观看"
|
||
@click="openVerticalViewer(videos[0])"
|
||
/>
|
||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||
<BatchSelectionMark
|
||
v-if="videoBatch.selectionMode.value && video.canDelete"
|
||
:selected="videoBatch.isSelected(video)"
|
||
:label="`视频:${video.title}`"
|
||
@toggle="videoBatch.toggleSelection(video)"
|
||
/>
|
||
<button
|
||
class="video-card__cover-action"
|
||
:aria-label="`播放${video.title}`"
|
||
@click="videoBatch.selectionMode.value && video.canDelete ? videoBatch.toggleSelection(video) : openVerticalViewer(video)"
|
||
>
|
||
<image
|
||
class="video-card__cover"
|
||
:src="video.coverFile?.accessUrl || '/static/assets/modules/genealogy/transparent/empty-panel-frame.png'"
|
||
mode="aspectFill"
|
||
/>
|
||
<text class="video-card__play-copy">点击播放</text>
|
||
</button>
|
||
<text class="video-card__title">{{ video.title }}</text>
|
||
<text v-if="video.description" class="video-card__copy">{{
|
||
video.description
|
||
}}</text>
|
||
<text class="video-card__meta">{{
|
||
video.publishTime || "刚刚发布"
|
||
}}</text>
|
||
<view
|
||
v-if="!videoBatch.selectionMode.value && (video.canEdit || video.canDelete)"
|
||
class="video-card__actions"
|
||
>
|
||
<AppButton
|
||
v-if="video.canEdit"
|
||
compact
|
||
type="secondary"
|
||
label="编辑视频"
|
||
@click="openEditVideo(video)"
|
||
/>
|
||
<AppButton
|
||
v-if="video.canDelete"
|
||
compact
|
||
type="secondary"
|
||
:disabled="deletingVideoId === video.id"
|
||
:label="deletingVideoId === video.id ? '正在删除' : '删除视频'"
|
||
@click="requestDeleteVideo(video)"
|
||
/>
|
||
</view>
|
||
<view v-if="!videoBatch.selectionMode.value" class="video-card__actions">
|
||
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
|
||
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
|
||
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<text v-if="videoActionError" class="field-error">{{
|
||
videoActionError
|
||
}}</text>
|
||
</view>
|
||
<view v-else class="video-state-card">
|
||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||
</view>
|
||
</view>
|
||
<AppDialog
|
||
:visible="videoBatch.confirmationVisible.value"
|
||
eyebrow="批量删除"
|
||
title="将选中的视频移入回收站?"
|
||
:message="videoBatch.confirmationMessage.value"
|
||
:confirm-text="videoBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||
cancel-text="继续选择"
|
||
show-cancel
|
||
:close-on-mask="false"
|
||
@confirm="videoBatch.confirmDelete"
|
||
@cancel="videoBatch.cancelDelete"
|
||
/>
|
||
<AppDialog
|
||
:visible="deleteConfirmVisible"
|
||
eyebrow="删除确认"
|
||
title="将这段家族视频移至回收站?"
|
||
:message="deleteTarget ? `《${deleteTarget.title}》移入回收站后不再展示,管理员可在保留期内恢复。` : ''"
|
||
:confirm-text="deletingVideoId ? '正在移除' : '移至回收站'"
|
||
cancel-text="保留视频"
|
||
show-cancel
|
||
:close-on-mask="false"
|
||
@confirm="confirmDeleteVideo"
|
||
@cancel="deleteConfirmVisible = false"
|
||
/>
|
||
<AppDialog
|
||
:visible="discardVisible"
|
||
eyebrow="未保存修改"
|
||
title="放弃视频修改?"
|
||
message="当前修改还没有保存。"
|
||
confirm-text="确认放弃"
|
||
cancel-text="继续编辑"
|
||
show-cancel
|
||
:close-on-mask="false"
|
||
@confirm="confirmDiscard"
|
||
@cancel="cancelDiscard"
|
||
/>
|
||
<AppDialog
|
||
:visible="Boolean(commentTarget)"
|
||
eyebrow="视频评论"
|
||
:title="commentTarget?.title || '家族视频'"
|
||
:confirm-text="videoActionKey === 'send-comment' ? '正在发送' : replyTarget ? '发送回复' : '发表评论'"
|
||
cancel-text="关闭"
|
||
show-cancel
|
||
:close-on-mask="false"
|
||
@confirm="sendVideoComment"
|
||
@cancel="closeVideoComments"
|
||
>
|
||
<view v-if="replyTarget" class="video-reply-target">
|
||
<text>正在回复 {{ replyTarget.author }}</text>
|
||
<button class="video-comment-action" @click="cancelVideoReply">取消回复</button>
|
||
</view>
|
||
<view v-if="videoComments.length" class="video-comments">
|
||
<view
|
||
v-for="comment in videoComments"
|
||
:key="comment.id"
|
||
class="video-comment"
|
||
:class="{ 'video-comment--reply': comment.level === 'reply' }"
|
||
>
|
||
<view class="video-comment__heading">
|
||
<text>{{ comment.author }}</text>
|
||
<text>{{ comment.time }}</text>
|
||
</view>
|
||
<text v-if="comment.parentAuthor" class="video-comment__context"
|
||
>回复 {{ comment.parentAuthor }}</text
|
||
>
|
||
<text class="video-comment__content">{{ comment.content }}</text>
|
||
<view v-if="!comment.userDeleted || comment.canDelete" class="video-comment__actions">
|
||
<button
|
||
v-if="!comment.userDeleted && comment.level === 'root'"
|
||
class="video-comment-action"
|
||
@click="startVideoReply(comment)"
|
||
>
|
||
回复
|
||
</button>
|
||
<button
|
||
v-if="comment.canDelete"
|
||
class="video-comment-action video-comment-action--danger"
|
||
@click="requestDeleteVideoComment(comment)"
|
||
>
|
||
删除
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<text v-else class="video-comments__empty">还没有评论,可以先说说你的看法。</text>
|
||
<text v-if="videoCommentError" class="field-error">{{ videoCommentError }}</text>
|
||
<textarea
|
||
v-model="videoCommentText"
|
||
maxlength="1000"
|
||
:placeholder="videoCommentPlaceholder"
|
||
class="video-comment-input"
|
||
@input="videoCommentError = ''"
|
||
/>
|
||
</AppDialog>
|
||
<VerticalVideoViewer
|
||
:visible="verticalViewerVisible"
|
||
:videos="videos"
|
||
:initial-video-id="verticalViewerInitialId"
|
||
title="家族视频"
|
||
:action-busy="Boolean(videoActionKey)"
|
||
@close="closeVerticalViewer"
|
||
@like="toggleVideoLike"
|
||
@comments="openVerticalViewerComments"
|
||
/>
|
||
<AppDialog
|
||
:visible="Boolean(commentDeleteTarget)"
|
||
:close-on-mask="false"
|
||
eyebrow="评论管理"
|
||
title="删除这条评论?"
|
||
message="删除后将按服务端规则保留占位或移除内容。"
|
||
:confirm-text="videoActionKey === 'delete-comment' ? '正在删除' : '确认删除'"
|
||
cancel-text="保留评论"
|
||
show-cancel
|
||
@confirm="deleteVideoComment"
|
||
@cancel="closeVideoCommentDelete"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, reactive, ref } from "vue";
|
||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||
import AppLoading from "@/components/AppLoading.vue";
|
||
import AppButton from "@/components/AppButton.vue";
|
||
import AppDialog from "@/components/AppDialog.vue";
|
||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||
import PageHeader from "@/components/PageHeader.vue";
|
||
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
||
import {
|
||
createRequestController,
|
||
isRequestCancelled
|
||
} from "@/services/api/request-controller.js";
|
||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||
import {
|
||
isImagePickCancelled,
|
||
isVideoPickCancelled,
|
||
pickAndUploadImage,
|
||
pickAndUploadVideo,
|
||
} from "@/utils/media-upload.js";
|
||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||
|
||
const genealogyId = ref("");
|
||
const pageState = ref("list");
|
||
const videoListState = ref("loading");
|
||
const videos = ref([]);
|
||
const verticalViewerVisible = ref(false);
|
||
const verticalViewerInitialId = ref("");
|
||
const receipt = ref(null);
|
||
const coverReceipt = ref(null);
|
||
const uploading = ref(false);
|
||
const coverUploading = ref(false);
|
||
const submitting = ref(false);
|
||
const uploadError = ref("");
|
||
const coverUploadError = ref("");
|
||
const submitError = ref("");
|
||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||
const editingVideo = ref(null);
|
||
const formBaseline = ref("");
|
||
const discardVisible = ref(false);
|
||
const videoListRequestController = createRequestController();
|
||
const videoDetailRequestController = createRequestController();
|
||
const videoUploadRequestController = createRequestController();
|
||
const coverUploadRequestController = createRequestController();
|
||
const videoSaveRequestController = createRequestController();
|
||
const videoDeletionRequestController = createRequestController();
|
||
const videoCommentListController = createRequestController();
|
||
const videoCommentWriteController = createRequestController();
|
||
const videoCommentDeleteController = createRequestController();
|
||
const videoCreateGuard = createNonIdempotentWriteGuard();
|
||
const videoCommentCreateGuard = createNonIdempotentWriteGuard();
|
||
const deleteTarget = ref(null);
|
||
const deleteConfirmVisible = ref(false);
|
||
const deletingVideoId = ref("");
|
||
const videoActionError = ref("");
|
||
const videoActionKey = ref("");
|
||
const commentTarget = ref(null);
|
||
const videoComments = ref([]);
|
||
const videoCommentText = ref("");
|
||
const videoCommentError = ref("");
|
||
const replyTarget = ref(null);
|
||
const commentDeleteTarget = ref(null);
|
||
let pageActive = true;
|
||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||
discardVisible.value = visible;
|
||
});
|
||
const confirmDiscard = discardConfirmation.confirm;
|
||
const cancelDiscard = discardConfirmation.cancel;
|
||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||
const videoBatch = useBatchDeletion({
|
||
items: videos,
|
||
deleteOne: (video) =>
|
||
familyMediaApi.deleteVideo(genealogyId.value, video.id, {
|
||
requestController: videoDeletionRequestController,
|
||
}),
|
||
resourceName: "视频",
|
||
isActive: () => pageActive,
|
||
onEmpty: () => {
|
||
videoListState.value = "ready";
|
||
},
|
||
});
|
||
const deletableVideos = videoBatch.deletableItems;
|
||
const videoCommentPlaceholder = computed(() =>
|
||
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
|
||
);
|
||
const openPlatformVideos = () => openPage("F11", { placement: "video_center" }, "F10");
|
||
const openVerticalViewer = (video) => {
|
||
if (!video || videoActionKey.value) return;
|
||
verticalViewerInitialId.value = String(video.id);
|
||
verticalViewerVisible.value = true;
|
||
};
|
||
const closeVerticalViewer = () => {
|
||
verticalViewerVisible.value = false;
|
||
};
|
||
const openVerticalViewerComments = (video) => {
|
||
closeVerticalViewer();
|
||
return openVideoComments(video);
|
||
};
|
||
const toggleVideoLike = async (video) => {
|
||
if (videoActionKey.value) return;
|
||
videoActionKey.value = `like-${video.id}`;
|
||
videoActionError.value = "";
|
||
const liked = !video.likedByCurrentUser;
|
||
try { await genealogyCapabilityApi.setVideoLike(genealogyId.value, video.id, liked); video.likedByCurrentUser = liked; video.likeCount = Math.max(0, Number(video.likeCount || 0) + (liked ? 1 : -1)); }
|
||
catch (error) { videoActionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。"); }
|
||
finally { videoActionKey.value = ""; }
|
||
};
|
||
const loadVideoCommentThread = async (video) => {
|
||
const rootComments = await genealogyCapabilityApi.getVideoComments(
|
||
genealogyId.value,
|
||
video.id,
|
||
{ requestController: videoCommentListController },
|
||
);
|
||
const thread = [];
|
||
for (const rootComment of rootComments) {
|
||
thread.push(rootComment);
|
||
if (rootComment.replyCount <= 0) continue;
|
||
const replies = await genealogyCapabilityApi.getVideoCommentReplies(
|
||
genealogyId.value,
|
||
video.id,
|
||
rootComment.id,
|
||
{ requestController: videoCommentListController },
|
||
);
|
||
thread.push(...replies.map((reply) => ({
|
||
...reply,
|
||
parentAuthor: rootComment.author,
|
||
})));
|
||
}
|
||
return thread;
|
||
};
|
||
const openVideoComments = async (video) => {
|
||
if (videoActionKey.value) return;
|
||
videoActionKey.value = `comments-${video.id}`;
|
||
videoActionError.value = "";
|
||
videoCommentListController.abort();
|
||
try {
|
||
videoComments.value = await loadVideoCommentThread(video);
|
||
commentTarget.value = video;
|
||
videoCommentText.value = "";
|
||
videoCommentError.value = "";
|
||
replyTarget.value = null;
|
||
}
|
||
catch (error) { videoActionError.value = getRequestErrorMessage(error, "评论暂时无法读取。"); }
|
||
finally { videoActionKey.value = ""; }
|
||
};
|
||
const closeVideoComments = () => {
|
||
if (videoActionKey.value === "send-comment" || videoActionKey.value === "delete-comment") return;
|
||
commentTarget.value = null;
|
||
videoComments.value = [];
|
||
videoCommentText.value = "";
|
||
videoCommentError.value = "";
|
||
replyTarget.value = null;
|
||
commentDeleteTarget.value = null;
|
||
};
|
||
const startVideoReply = (comment) => {
|
||
if (!comment || comment.level !== "root" || comment.userDeleted || videoActionKey.value) return;
|
||
replyTarget.value = comment;
|
||
videoCommentText.value = "";
|
||
videoCommentError.value = "";
|
||
};
|
||
const cancelVideoReply = () => {
|
||
if (videoActionKey.value) return;
|
||
replyTarget.value = null;
|
||
videoCommentText.value = "";
|
||
videoCommentError.value = "";
|
||
};
|
||
const sendVideoComment = async () => {
|
||
if (!commentTarget.value || !videoCommentText.value.trim() || videoActionKey.value) return;
|
||
const commentPayload = {
|
||
genealogyId: genealogyId.value,
|
||
videoId: commentTarget.value.id,
|
||
commentContent: videoCommentText.value.trim(),
|
||
parentCommentId: replyTarget.value?.id || null,
|
||
};
|
||
const commentAttempt = videoCommentCreateGuard.begin(commentPayload);
|
||
if (commentAttempt === null) {
|
||
videoCommentError.value = "上次评论发送结果待确认,请先重新打开评论列表,避免重复发布。";
|
||
return;
|
||
}
|
||
videoActionKey.value = "send-comment";
|
||
videoCommentError.value = "";
|
||
try {
|
||
const comment = await genealogyCapabilityApi.createVideoComment(
|
||
commentPayload.genealogyId,
|
||
commentPayload.videoId,
|
||
commentPayload.commentContent,
|
||
commentPayload.parentCommentId,
|
||
{ requestController: videoCommentWriteController },
|
||
);
|
||
if (!pageActive) return;
|
||
if (commentPayload.parentCommentId) {
|
||
const rootComment = videoComments.value.find(
|
||
(currentComment) => currentComment.id === commentPayload.parentCommentId,
|
||
);
|
||
const insertedReply = {
|
||
...comment,
|
||
parentAuthor: rootComment?.author || replyTarget.value?.author || "",
|
||
};
|
||
let insertionIndex = videoComments.value.findIndex(
|
||
(currentComment) => currentComment.id === commentPayload.parentCommentId,
|
||
);
|
||
for (let index = insertionIndex + 1; index < videoComments.value.length; index += 1) {
|
||
if (videoComments.value[index].parentCommentId !== commentPayload.parentCommentId) break;
|
||
insertionIndex = index;
|
||
}
|
||
videoComments.value.splice(insertionIndex + 1, 0, insertedReply);
|
||
if (rootComment) rootComment.replyCount += 1;
|
||
} else {
|
||
videoComments.value.push(comment);
|
||
}
|
||
videoCommentText.value = "";
|
||
replyTarget.value = null;
|
||
}
|
||
catch (error) {
|
||
const isOutcomeUnknown = videoCommentCreateGuard.recordFailure(commentAttempt, error);
|
||
if (!pageActive) return;
|
||
videoCommentError.value = isOutcomeUnknown
|
||
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
|
||
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
|
||
}
|
||
finally { if (pageActive) videoActionKey.value = ""; }
|
||
};
|
||
const requestDeleteVideoComment = (comment) => {
|
||
if (!comment?.canDelete || videoActionKey.value) return;
|
||
videoCommentError.value = "";
|
||
commentDeleteTarget.value = comment;
|
||
};
|
||
const closeVideoCommentDelete = () => {
|
||
if (videoActionKey.value !== "delete-comment") commentDeleteTarget.value = null;
|
||
};
|
||
const deleteVideoComment = async () => {
|
||
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || videoActionKey.value) return;
|
||
const target = commentDeleteTarget.value;
|
||
videoActionKey.value = "delete-comment";
|
||
videoCommentError.value = "";
|
||
try {
|
||
await genealogyCapabilityApi.deleteVideoComment(
|
||
genealogyId.value,
|
||
commentTarget.value.id,
|
||
target.id,
|
||
{ requestController: videoCommentDeleteController },
|
||
);
|
||
if (!pageActive) return;
|
||
videoComments.value = await loadVideoCommentThread(commentTarget.value);
|
||
if (replyTarget.value?.id === target.id) replyTarget.value = null;
|
||
commentDeleteTarget.value = null;
|
||
} catch (error) {
|
||
if (!pageActive || isRequestCancelled(error)) return;
|
||
videoCommentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
|
||
commentDeleteTarget.value = null;
|
||
} finally {
|
||
if (pageActive) videoActionKey.value = "";
|
||
}
|
||
};
|
||
const isEdit = computed(() => Boolean(editingVideo.value));
|
||
const formSnapshot = computed(() => JSON.stringify({
|
||
videoTitle: form.videoTitle,
|
||
videoDesc: form.videoDesc,
|
||
videoOssId: receipt.value?.ossId || null,
|
||
coverOssId: coverReceipt.value?.ossId || null,
|
||
editingVideoId: editingVideo.value?.id || null,
|
||
}));
|
||
const isDirty = computed(() =>
|
||
pageState.value === "form" &&
|
||
Boolean(formBaseline.value) &&
|
||
formSnapshot.value !== formBaseline.value,
|
||
);
|
||
const stateCopy = computed(() =>
|
||
pageState.value === "success"
|
||
? {
|
||
title: "视频已保存",
|
||
copy: "视频已关联到当前家谱,播放内容准备好后会在这里显示。",
|
||
action: "返回家族动态",
|
||
}
|
||
: {
|
||
title: "暂时无法发布视频",
|
||
copy: "未找到家谱信息,请返回后重新进入。",
|
||
action: "返回上一页",
|
||
},
|
||
);
|
||
|
||
onLoad((query) => {
|
||
genealogyId.value = String(query?.genealogyId || "");
|
||
if (!hasValidContext.value) pageState.value = "invalid";
|
||
else loadVideos();
|
||
});
|
||
onUnload(() => {
|
||
pageActive = false;
|
||
videoListRequestController.abort();
|
||
videoDetailRequestController.abort();
|
||
videoUploadRequestController.abort();
|
||
coverUploadRequestController.abort();
|
||
videoSaveRequestController.abort();
|
||
videoDeletionRequestController.abort();
|
||
videoCommentListController.abort();
|
||
videoCommentWriteController.abort();
|
||
videoCommentDeleteController.abort();
|
||
discardConfirmation.dispose();
|
||
});
|
||
|
||
const loadVideos = async () => {
|
||
if (!hasValidContext.value) return;
|
||
videoListState.value = "loading";
|
||
try {
|
||
const videoRows = await familyMediaApi.getVideos(genealogyId.value, {
|
||
requestController: videoListRequestController,
|
||
});
|
||
if (!pageActive) return;
|
||
videos.value = videoRows;
|
||
videoBatch.exitSelectionMode();
|
||
videoListState.value = "ready";
|
||
} catch (error) {
|
||
if (!pageActive || isRequestCancelled(error)) return;
|
||
videoListState.value = "error";
|
||
}
|
||
};
|
||
const openPublishForm = () => {
|
||
if (!hasValidContext.value) return;
|
||
receipt.value = null;
|
||
coverReceipt.value = null;
|
||
editingVideo.value = null;
|
||
form.videoTitle = "";
|
||
form.videoDesc = "";
|
||
uploadError.value = "";
|
||
coverUploadError.value = "";
|
||
submitError.value = "";
|
||
pageState.value = "form";
|
||
formBaseline.value = formSnapshot.value;
|
||
};
|
||
const openEditVideo = async (video) => {
|
||
if (
|
||
!video?.canEdit ||
|
||
uploading.value ||
|
||
coverUploading.value ||
|
||
submitting.value
|
||
)
|
||
return;
|
||
pageState.value = "form-loading";
|
||
videoActionError.value = "";
|
||
try {
|
||
const detail = await familyMediaApi.getVideoDetail(genealogyId.value, video.id, {
|
||
requestController: videoDetailRequestController,
|
||
});
|
||
if (
|
||
!detail.canEdit ||
|
||
!detail.videoFile?.ossId ||
|
||
!["0", "1"].includes(detail.status) ||
|
||
!Number.isSafeInteger(detail.durationSeconds) ||
|
||
!Number.isSafeInteger(detail.sortOrder)
|
||
) {
|
||
throw new Error("视频信息不完整,暂未保存修改,以免覆盖原内容。");
|
||
}
|
||
receipt.value = {
|
||
ossId: detail.videoFile.ossId,
|
||
fileName: detail.videoFile.fileName,
|
||
};
|
||
coverReceipt.value = detail.coverFile
|
||
? { ossId: detail.coverFile.ossId, fileName: detail.coverFile.fileName }
|
||
: null;
|
||
form.videoTitle = detail.title;
|
||
form.videoDesc = detail.description;
|
||
editingVideo.value = {
|
||
id: detail.id,
|
||
durationSeconds: detail.durationSeconds,
|
||
sortOrder: detail.sortOrder,
|
||
status: detail.status,
|
||
};
|
||
uploadError.value = "";
|
||
coverUploadError.value = "";
|
||
submitError.value = "";
|
||
pageState.value = "form";
|
||
formBaseline.value = formSnapshot.value;
|
||
} catch (error) {
|
||
if (!isRequestCancelled(error)) {
|
||
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
|
||
pageState.value = "list";
|
||
}
|
||
}
|
||
};
|
||
const requestDeleteVideo = (video) => {
|
||
if (!video?.canDelete || deletingVideoId.value) return;
|
||
videoActionError.value = "";
|
||
deleteTarget.value = video;
|
||
deleteConfirmVisible.value = true;
|
||
};
|
||
const confirmDeleteVideo = async () => {
|
||
const target = deleteTarget.value;
|
||
if (!target?.canDelete || deletingVideoId.value) return;
|
||
deletingVideoId.value = target.id;
|
||
videoActionError.value = "";
|
||
try {
|
||
await familyMediaApi.deleteVideo(genealogyId.value, target.id, {
|
||
requestController: videoDeletionRequestController,
|
||
});
|
||
if (!pageActive) return;
|
||
deleteConfirmVisible.value = false;
|
||
deleteTarget.value = null;
|
||
await loadVideos();
|
||
} catch (error) {
|
||
if (!pageActive || isRequestCancelled(error)) return;
|
||
videoActionError.value = getRequestErrorMessage(
|
||
error,
|
||
"视频删除失败,请稍后重试",
|
||
);
|
||
deleteConfirmVisible.value = false;
|
||
} finally {
|
||
if (pageActive) deletingVideoId.value = "";
|
||
}
|
||
};
|
||
|
||
const selectVideo = async () => {
|
||
if (
|
||
isEdit.value ||
|
||
uploading.value ||
|
||
coverUploading.value ||
|
||
submitting.value
|
||
)
|
||
return;
|
||
uploading.value = true;
|
||
uploadError.value = "";
|
||
try {
|
||
const videoReceipt = await pickAndUploadVideo({
|
||
requestController: videoUploadRequestController,
|
||
});
|
||
if (!pageActive) return;
|
||
receipt.value = videoReceipt;
|
||
} catch (error) {
|
||
if (pageActive && !isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||
uploadError.value = getRequestErrorMessage(error, "视频上传失败,请稍后重试");
|
||
}
|
||
} finally {
|
||
if (pageActive) uploading.value = false;
|
||
}
|
||
};
|
||
const selectCover = async () => {
|
||
if (uploading.value || coverUploading.value || submitting.value) return;
|
||
coverUploading.value = true;
|
||
coverUploadError.value = "";
|
||
try {
|
||
const uploadedCover = await pickAndUploadImage({
|
||
requestController: coverUploadRequestController,
|
||
});
|
||
if (!pageActive) return;
|
||
coverReceipt.value = uploadedCover;
|
||
} catch (error) {
|
||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||
coverUploadError.value = getRequestErrorMessage(
|
||
error,
|
||
"视频封面上传失败,请稍后重试",
|
||
);
|
||
}
|
||
} finally {
|
||
if (pageActive) coverUploading.value = false;
|
||
}
|
||
};
|
||
const clearCover = () => {
|
||
if (coverUploading.value || submitting.value) return;
|
||
coverReceipt.value = null;
|
||
coverUploadError.value = "";
|
||
};
|
||
const submitVideo = async () => {
|
||
if (
|
||
uploading.value ||
|
||
coverUploading.value ||
|
||
submitting.value ||
|
||
!hasValidContext.value
|
||
)
|
||
return;
|
||
const videoTitle = form.videoTitle.trim();
|
||
if (!receipt.value) {
|
||
submitError.value = "请先选择并上传视频";
|
||
return;
|
||
}
|
||
if (!videoTitle) {
|
||
submitError.value = "请填写视频标题";
|
||
return;
|
||
}
|
||
const payload = {
|
||
videoTitle,
|
||
videoDesc: form.videoDesc.trim(),
|
||
videoOssId: receipt.value.ossId,
|
||
coverOssId: coverReceipt.value?.ossId ?? null,
|
||
...(editingVideo.value
|
||
? {
|
||
durationSeconds: editingVideo.value.durationSeconds,
|
||
sortOrder: editingVideo.value.sortOrder,
|
||
status: editingVideo.value.status,
|
||
}
|
||
: {}),
|
||
};
|
||
const createAttempt = editingVideo.value ? null : videoCreateGuard.begin(payload);
|
||
if (!editingVideo.value && createAttempt === null) {
|
||
submitError.value =
|
||
"上次发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||
return;
|
||
}
|
||
|
||
submitting.value = true;
|
||
submitError.value = "";
|
||
try {
|
||
if (editingVideo.value) {
|
||
await familyMediaApi.updateVideo(
|
||
genealogyId.value,
|
||
editingVideo.value.id,
|
||
payload,
|
||
{
|
||
requestController: videoSaveRequestController,
|
||
},
|
||
);
|
||
} else {
|
||
await familyMediaApi.createVideo(genealogyId.value, payload, {
|
||
requestController: videoSaveRequestController,
|
||
});
|
||
}
|
||
if (!pageActive) return;
|
||
editingVideo.value = null;
|
||
pageState.value = "list";
|
||
await loadVideos();
|
||
} catch (error) {
|
||
if (!pageActive) return;
|
||
if (!editingVideo.value && videoCreateGuard.recordFailure(createAttempt, error)) {
|
||
submitError.value =
|
||
"发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||
return;
|
||
}
|
||
if (!isRequestCancelled(error))
|
||
submitError.value = getRequestErrorMessage(error, "视频发布失败,请稍后重试");
|
||
} finally {
|
||
if (pageActive) submitting.value = false;
|
||
}
|
||
};
|
||
const returnToFamily = () =>
|
||
hasValidContext.value
|
||
? returnTo("G05", { genealogyId: genealogyId.value })
|
||
: goBack();
|
||
const returnToVideoList = () => {
|
||
pageState.value = "list";
|
||
formBaseline.value = "";
|
||
receipt.value = null;
|
||
coverReceipt.value = null;
|
||
editingVideo.value = null;
|
||
return true;
|
||
};
|
||
const requestBack = async () => {
|
||
if (videoBatch.deleting.value) return true;
|
||
if (videoBatch.confirmationVisible.value) {
|
||
videoBatch.cancelDelete();
|
||
return true;
|
||
}
|
||
if (videoBatch.selectionMode.value) {
|
||
videoBatch.exitSelectionMode();
|
||
return true;
|
||
}
|
||
if (verticalViewerVisible.value) {
|
||
closeVerticalViewer();
|
||
return true;
|
||
}
|
||
if (commentDeleteTarget.value) {
|
||
closeVideoCommentDelete();
|
||
return true;
|
||
}
|
||
if (commentTarget.value) {
|
||
closeVideoComments();
|
||
return true;
|
||
}
|
||
if (deleteConfirmVisible.value) {
|
||
deleteConfirmVisible.value = false;
|
||
return true;
|
||
}
|
||
if (discardVisible.value) {
|
||
cancelDiscard();
|
||
return true;
|
||
}
|
||
if (uploading.value || coverUploading.value || submitting.value || deletingVideoId.value) {
|
||
return true;
|
||
}
|
||
if (pageState.value === "form") {
|
||
if (isDirty.value && !(await discardConfirmation.request())) return false;
|
||
return returnToVideoList();
|
||
}
|
||
if (pageState.value === "form-loading") {
|
||
videoDetailRequestController.abort();
|
||
return returnToVideoList();
|
||
}
|
||
return returnToFamily();
|
||
};
|
||
const handleStateAction = () => returnToFamily();
|
||
onBackPress((event) => handleBackPress(event, requestBack));
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
@import "../../styles/adaptive-frame-profiles.scss";
|
||
.video-page {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
flex-direction: column;
|
||
background: $paper;
|
||
}
|
||
.page-header,
|
||
.page-content {
|
||
z-index: 1;
|
||
}
|
||
.page-content {
|
||
flex: 1;
|
||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||
}
|
||
.video-panel,
|
||
.video-state-card {
|
||
box-sizing: border-box;
|
||
@include adaptive-family-content;
|
||
background-color: rgba($paper, 0.82);
|
||
}
|
||
.video-list-panel {
|
||
@include adaptive-family-content;
|
||
box-sizing: border-box;
|
||
padding: 28rpx 24rpx;
|
||
background-color: rgba($paper, 0.82);
|
||
}
|
||
.video-panel {
|
||
padding: 30rpx;
|
||
}
|
||
.video-panel__title,
|
||
.video-panel__note,
|
||
.video-field__label,
|
||
.video-state-card text {
|
||
display: block;
|
||
}
|
||
.video-panel__title,
|
||
.video-state-card__title {
|
||
color: $ink;
|
||
font-family: STKaiti, KaiTi, serif;
|
||
font-size: clamp(19px, 34rpx, 24px);
|
||
font-weight: 700;
|
||
}
|
||
.video-panel__note,
|
||
.video-state-card__copy {
|
||
margin-top: 12rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
line-height: 1.6;
|
||
}
|
||
.video-field {
|
||
margin-top: 24rpx;
|
||
}
|
||
.video-field__label {
|
||
margin-bottom: 10rpx;
|
||
color: $ink;
|
||
font-size: clamp(15px, 24rpx, 18px);
|
||
font-weight: 700;
|
||
}
|
||
.video-field input,
|
||
.video-field textarea {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||
border-radius: 8rpx;
|
||
background: rgba(255, 253, 247, 0.8);
|
||
color: $ink;
|
||
font-size: clamp(15px, 24rpx, 18px);
|
||
}
|
||
.video-field input {
|
||
min-height: 80rpx;
|
||
padding: 0 18rpx;
|
||
}
|
||
.video-field textarea {
|
||
min-height: 140rpx;
|
||
padding: 16rpx 18rpx;
|
||
}
|
||
.video-field--upload {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
.video-field--upload .video-field__label {
|
||
width: 100%;
|
||
}
|
||
.upload-button {
|
||
min-height: 80rpx;
|
||
margin: 0;
|
||
padding: 0 26rpx;
|
||
border: 1rpx solid #b78a42;
|
||
border-radius: 8rpx;
|
||
background: #fffaf0;
|
||
color: #805723;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
line-height: 78rpx;
|
||
}
|
||
.upload-receipt {
|
||
color: $ink-muted;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
}
|
||
.remove-cover-button {
|
||
justify-self: start;
|
||
min-height: 72rpx;
|
||
margin: 0;
|
||
padding: 0 18rpx;
|
||
border: 1rpx solid rgba($brand-red, 0.38);
|
||
border-radius: 8rpx;
|
||
background: transparent;
|
||
color: $brand-red;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
}
|
||
.remove-cover-button::after { border: 0; }
|
||
.required-mark,
|
||
.field-error {
|
||
color: $brand-red;
|
||
}
|
||
.field-error {
|
||
display: block;
|
||
margin-top: 12rpx;
|
||
font-size: clamp(14px, 22rpx, 17px);
|
||
}
|
||
.video-panel .app-button {
|
||
margin-top: 28rpx;
|
||
}
|
||
.video-state-card {
|
||
min-height: 340rpx;
|
||
padding: 78rpx 52rpx 50rpx;
|
||
text-align: center;
|
||
}
|
||
.video-state-card .app-button {
|
||
margin-top: 28rpx;
|
||
}
|
||
.video-card-list {
|
||
display: grid;
|
||
gap: 20rpx;
|
||
}
|
||
.video-card {
|
||
padding: 22rpx;
|
||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||
border-radius: 12rpx;
|
||
background: rgba($paper, 0.9);
|
||
}
|
||
.video-card__cover-action {
|
||
position: relative;
|
||
display: block;
|
||
width: 100%;
|
||
height: 340rpx;
|
||
overflow: hidden;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 8rpx;
|
||
background: #161616;
|
||
line-height: 1;
|
||
}
|
||
.video-card__cover-action::after {
|
||
border: 0;
|
||
}
|
||
.video-card__cover {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.video-card__play-copy {
|
||
position: absolute;
|
||
right: 20rpx;
|
||
bottom: 18rpx;
|
||
padding: 10rpx 16rpx;
|
||
border-radius: 8rpx;
|
||
background: rgba(22, 22, 22, 0.78);
|
||
color: #fff9ed;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
font-weight: 700;
|
||
}
|
||
.video-card__title,
|
||
.video-card__copy,
|
||
.video-card__meta {
|
||
display: block;
|
||
}
|
||
.video-card__title {
|
||
margin-top: 16rpx;
|
||
color: $ink;
|
||
font-size: clamp(17px, 28rpx, 21px);
|
||
font-weight: 700;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.video-card__copy {
|
||
margin-top: 8rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
line-height: 1.55;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.video-card__meta {
|
||
margin-top: 10rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
}
|
||
.video-card__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 14rpx;
|
||
}
|
||
.video-reply-target,
|
||
.video-comment__heading,
|
||
.video-comment__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14rpx;
|
||
}
|
||
.video-reply-target {
|
||
width: 100%;
|
||
margin-top: 18rpx;
|
||
padding: 14rpx 16rpx;
|
||
box-sizing: border-box;
|
||
border-radius: 8rpx;
|
||
background: rgba($gold, 0.1);
|
||
color: $ink;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
}
|
||
.video-comments {
|
||
width: 100%;
|
||
max-height: 440rpx;
|
||
margin-top: 16rpx;
|
||
overflow-y: auto;
|
||
text-align: left;
|
||
}
|
||
.video-comment {
|
||
padding: 18rpx 4rpx;
|
||
border-bottom: 1rpx solid rgba($gold, 0.2);
|
||
}
|
||
.video-comment--reply {
|
||
margin-left: 32rpx;
|
||
padding-left: 18rpx;
|
||
border-left: 3rpx solid rgba($gold, 0.34);
|
||
}
|
||
.video-comment__heading text:first-child {
|
||
color: $ink;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
font-weight: 700;
|
||
}
|
||
.video-comment__heading text:last-child,
|
||
.video-comment__context {
|
||
color: $ink-muted;
|
||
font-size: clamp(12px, 20rpx, 15px);
|
||
}
|
||
.video-comment__context,
|
||
.video-comment__content,
|
||
.video-comments__empty {
|
||
display: block;
|
||
}
|
||
.video-comment__context {
|
||
margin-top: 6rpx;
|
||
}
|
||
.video-comment__content {
|
||
margin-top: 8rpx;
|
||
color: $ink;
|
||
font-size: clamp(14px, 22rpx, 17px);
|
||
line-height: 1.55;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.video-comment__actions {
|
||
justify-content: flex-end;
|
||
margin-top: 8rpx;
|
||
}
|
||
.video-comment-action {
|
||
min-width: 88rpx;
|
||
min-height: 58rpx;
|
||
margin: 0;
|
||
padding: 0 14rpx;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #805723;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
line-height: 58rpx;
|
||
}
|
||
.video-comment-action::after {
|
||
border: 0;
|
||
}
|
||
.video-comment-action--danger {
|
||
color: $brand-red;
|
||
}
|
||
.video-comments__empty {
|
||
width: 100%;
|
||
margin-top: 20rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(14px, 22rpx, 17px);
|
||
}
|
||
.video-comment-input {
|
||
width: 100%;
|
||
min-height: 120rpx;
|
||
margin-top: 18rpx;
|
||
padding: 16rpx;
|
||
box-sizing: border-box;
|
||
border: 1rpx solid rgba($gold, 0.38);
|
||
border-radius: 10rpx;
|
||
color: $ink;
|
||
text-align: left;
|
||
}
|
||
</style>
|