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

1233 lines
39 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="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>