Files
jiapuapp/pages/family/videos.vue
T

661 lines
19 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="returnToFamily"
@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
>
</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">
<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">
<view v-for="video in videos" :key="video.id" class="video-card">
<video
class="video-card__player"
:src="video.videoFile.accessUrl"
controls
/>
<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="video.canEdit || video.canDelete"
class="video-card__actions"
>
<AppButton
v-if="video.canEdit"
compact
type="secondary"
label="编辑视频"
@click="openEditVideo(video)"
/>
<AppButton
compact
type="secondary"
:disabled="deletingVideoId === video.id"
:label="deletingVideoId === video.id ? '正在删除' : '删除视频'"
@click="requestDeleteVideo(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="deleteConfirmVisible"
eyebrow="删除确认"
title="删除这段家族视频?"
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
:confirm-text="deletingVideoId ? '正在删除' : '确认删除'"
cancel-text="保留视频"
show-cancel
:close-on-mask="false"
@confirm="confirmDeleteVideo"
@cancel="deleteConfirmVisible = false"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { 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 ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
isImagePickCancelled,
isVideoPickCancelled,
pickAndUploadImage,
pickAndUploadVideo,
} from "@/utils/media-upload.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const pageState = ref("list");
const videoListState = ref("loading");
const videos = 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 videoListRequestController = createRequestController();
const videoDetailRequestController = createRequestController();
const videoUploadRequestController = createRequestController();
const coverUploadRequestController = createRequestController();
const videoSaveRequestController = createRequestController();
const videoDeletionRequestController = createRequestController();
const videoCreateGuard = createNonIdempotentWriteGuard();
const deleteTarget = ref(null);
const deleteConfirmVisible = ref(false);
const deletingVideoId = ref("");
const videoActionError = ref("");
let pageActive = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isEdit = computed(() => Boolean(editingVideo.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();
});
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;
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";
};
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";
} 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 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,
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
...(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("F01", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () => returnToFamily();
</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 72rpx;
}
.video-panel,
.video-state-card {
box-sizing: border-box;
@include adaptive-family-content;
}
.video-list-panel {
@include adaptive-family-content;
}
.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: 76rpx;
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 {
margin: 0;
padding: 0 26rpx;
border: 1rpx solid #b78a42;
border-radius: 8rpx;
background: #fffaf0;
color: #805723;
font-size: clamp(14px, 23rpx, 17px);
line-height: 64rpx;
}
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.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(255, 252, 245, 0.78);
}
.video-card__player {
display: block;
width: 100%;
min-height: 340rpx;
border-radius: 8rpx;
background: #161616;
}
.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;
}
.video-card__copy {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
}
.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;
}
</style>