feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -308,6 +308,7 @@ onUnload(() => {
|
||||
.media-state-card {
|
||||
@include adaptive-family-panel;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.media-panel {
|
||||
padding: 38rpx 32rpx 42rpx;
|
||||
@@ -357,16 +358,16 @@ onUnload(() => {
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.media-field input {
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 0 22rpx;
|
||||
}
|
||||
.media-field--picker picker {
|
||||
display: block;
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.media-field--picker picker > view {
|
||||
display: flex;
|
||||
min-height: 78rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
padding: 0 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
|
||||
+324
-22
@@ -4,10 +4,10 @@
|
||||
<view class="album-detail-header">
|
||||
<PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
:action="headerAction"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
@back="requestBack"
|
||||
@action="handleHeaderAction"
|
||||
/>
|
||||
</view>
|
||||
<view class="album-detail-content">
|
||||
@@ -28,20 +28,67 @@
|
||||
<AppButton block label="添加照片" @click="addPhoto" />
|
||||
</view>
|
||||
<view v-else class="photo-list">
|
||||
<view v-if="deletablePhotos.length" class="photo-management">
|
||||
<template v-if="selectionMode">
|
||||
<view class="photo-management__summary">
|
||||
<text>已选择 {{ selectedPhotoIds.length }} 张</text>
|
||||
<text>仅可选择有删除权限的照片</text>
|
||||
</view>
|
||||
<view class="photo-management__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="batchDeleting"
|
||||
:label="allDeletableSelected ? '取消全选' : '全选'"
|
||||
@click="toggleAllDeletablePhotos"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
:disabled="!selectedPhotoIds.length || batchDeleting"
|
||||
:label="batchDeleteButtonLabel"
|
||||
@click="requestBatchDelete"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="photo-management__summary">
|
||||
<text>批量管理照片</text>
|
||||
<text>可一次选择多张照片删除</text>
|
||||
</view>
|
||||
<AppButton compact type="secondary" label="管理照片" @click="enterSelectionMode" />
|
||||
</template>
|
||||
</view>
|
||||
<text v-if="deleteNotice" class="photo-list__notice" role="status">{{ deleteNotice }}</text>
|
||||
<text v-if="deleteError" class="photo-list__error">{{ deleteError }}</text>
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card">
|
||||
<view
|
||||
v-for="item in photos"
|
||||
:key="item.id"
|
||||
class="photo-card"
|
||||
:class="{ 'photo-card--selected': isPhotoSelected(item) }"
|
||||
>
|
||||
<view
|
||||
v-if="selectionMode && item.canDelete"
|
||||
class="photo-card__selection"
|
||||
role="checkbox"
|
||||
:aria-checked="isPhotoSelected(item)"
|
||||
:aria-label="`${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}`"
|
||||
@click="togglePhotoSelection(item)"
|
||||
>
|
||||
<text>{{ isPhotoSelected(item) ? "已选择" : "选择" }}</text>
|
||||
</view>
|
||||
<image
|
||||
class="photo-card__image"
|
||||
:src="item.photoFile.accessUrl"
|
||||
mode="widthFix"
|
||||
role="button"
|
||||
:aria-label="`查看大图:${item.title}`"
|
||||
@click="previewPhoto(item)"
|
||||
:role="selectionMode && item.canDelete ? 'checkbox' : 'button'"
|
||||
:aria-checked="selectionMode && item.canDelete ? isPhotoSelected(item) : undefined"
|
||||
:aria-label="selectionMode && item.canDelete ? `${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}` : `查看大图:${item.title}`"
|
||||
@click="handlePhotoClick(item)"
|
||||
/>
|
||||
<text>{{ item.title }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
<text v-if="item.meta">{{ item.meta }}</text>
|
||||
<view v-if="item.canDelete" class="photo-card__actions">
|
||||
<text class="photo-card__title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="photo-card__copy">{{ item.description }}</text>
|
||||
<text v-if="item.meta" class="photo-card__meta">{{ item.meta }}</text>
|
||||
<view v-if="item.canDelete && !selectionMode" class="photo-card__actions">
|
||||
<AppButton compact type="secondary" label="删除照片" @click.stop="requestDeletePhoto(item)" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -51,20 +98,32 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这张照片?"
|
||||
message="删除后无法恢复,请确认影像已另行保存。"
|
||||
confirm-text="确认删除"
|
||||
title="将这张照片移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留照片"
|
||||
show-cancel
|
||||
@confirm="deletePhoto"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="batchDeleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="批量移除"
|
||||
title="将选中的照片移至回收站?"
|
||||
:message="batchDeleteConfirmationMessage"
|
||||
:confirm-text="batchDeleting ? '正在移除' : '移至回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
@confirm="deleteSelectedPhotos"
|
||||
@cancel="closeBatchDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
@@ -76,7 +135,7 @@ import {
|
||||
} 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 { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
@@ -86,6 +145,11 @@ const deleteTarget = ref(null);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deletingPhotoId = ref("");
|
||||
const deleteError = ref("");
|
||||
const deleteNotice = ref("");
|
||||
const selectionMode = ref(false);
|
||||
const selectedPhotoIds = ref([]);
|
||||
const batchDeleteConfirmationVisible = ref(false);
|
||||
const batchDeleting = ref(false);
|
||||
const albumPhotoListController = createRequestController();
|
||||
const albumPhotoDeleteController = createRequestController();
|
||||
let isPageActive = true;
|
||||
@@ -93,6 +157,34 @@ const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||
);
|
||||
const headerAction = computed(() => {
|
||||
if (!valid.value) return "";
|
||||
return selectionMode.value ? "完成" : "添加";
|
||||
});
|
||||
const deletablePhotos = computed(() => photos.value.filter((photo) => photo.canDelete));
|
||||
const allDeletableSelected = computed(
|
||||
() =>
|
||||
deletablePhotos.value.length > 0 &&
|
||||
deletablePhotos.value.every((photo) => selectedPhotoIds.value.includes(photo.id)),
|
||||
);
|
||||
const batchDeleteConfirmationMessage = computed(() =>
|
||||
`将选中的 ${selectedPhotoIds.value.length} 张照片移入回收站;家谱管理员可在保留期内恢复。`,
|
||||
);
|
||||
const batchDeleteButtonLabel = computed(() =>
|
||||
batchDeleting.value
|
||||
? "正在删除"
|
||||
: selectedPhotoIds.value.length
|
||||
? `删除 ${selectedPhotoIds.value.length}`
|
||||
: "删除",
|
||||
);
|
||||
const deleteInProgress = computed(
|
||||
() => Boolean(deletingPhotoId.value) || batchDeleting.value,
|
||||
);
|
||||
const resetPhotoSelection = () => {
|
||||
selectionMode.value = false;
|
||||
selectedPhotoIds.value = [];
|
||||
batchDeleteConfirmationVisible.value = false;
|
||||
};
|
||||
const loadPhotos = async () => {
|
||||
if (!valid.value) return;
|
||||
albumPhotoListController.abort();
|
||||
@@ -106,6 +198,7 @@ const loadPhotos = async () => {
|
||||
...photo,
|
||||
meta: [photo.photographer, photo.shootTime].filter(Boolean).join(" · "),
|
||||
}));
|
||||
resetPhotoSelection();
|
||||
albumPhotoListState.value = photos.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
@@ -124,15 +217,54 @@ const addPhoto = () =>
|
||||
"F08",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const handleHeaderAction = () => {
|
||||
if (selectionMode.value) {
|
||||
resetPhotoSelection();
|
||||
return;
|
||||
}
|
||||
addPhoto();
|
||||
};
|
||||
const enterSelectionMode = () => {
|
||||
if (!deletablePhotos.value.length || deleteInProgress.value) return;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
selectedPhotoIds.value = [];
|
||||
selectionMode.value = true;
|
||||
};
|
||||
const isPhotoSelected = (photo) => selectedPhotoIds.value.includes(photo?.id);
|
||||
const togglePhotoSelection = (photo) => {
|
||||
if (!selectionMode.value || !photo?.canDelete || batchDeleting.value) return;
|
||||
selectedPhotoIds.value = isPhotoSelected(photo)
|
||||
? selectedPhotoIds.value.filter((photoId) => photoId !== photo.id)
|
||||
: [...selectedPhotoIds.value, photo.id];
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
};
|
||||
const toggleAllDeletablePhotos = () => {
|
||||
if (!selectionMode.value || batchDeleting.value) return;
|
||||
selectedPhotoIds.value = allDeletableSelected.value
|
||||
? []
|
||||
: deletablePhotos.value.map((photo) => photo.id);
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
};
|
||||
const previewPhoto = (photo) => {
|
||||
const urls = photos.value.map((item) => item.photoFile?.accessUrl).filter(Boolean);
|
||||
const current = photo?.photoFile?.accessUrl;
|
||||
if (!current || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current, urls });
|
||||
};
|
||||
const handlePhotoClick = (photo) => {
|
||||
if (selectionMode.value && photo?.canDelete) {
|
||||
togglePhotoSelection(photo);
|
||||
return;
|
||||
}
|
||||
previewPhoto(photo);
|
||||
};
|
||||
const requestDeletePhoto = (photo) => {
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
if (!photo?.canDelete || deleteInProgress.value) return;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
deleteTarget.value = photo;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
@@ -142,6 +274,22 @@ const closeDeleteConfirmation = () => {
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (deleteInProgress.value) return true;
|
||||
if (batchDeleteConfirmationVisible.value) {
|
||||
closeBatchDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (deleteConfirmationVisible.value) {
|
||||
closeDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (selectionMode.value) {
|
||||
resetPhotoSelection();
|
||||
return true;
|
||||
}
|
||||
return returnToAlbums();
|
||||
};
|
||||
const deletePhoto = async () => {
|
||||
const photo = deleteTarget.value;
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
@@ -163,6 +311,68 @@ const deletePhoto = async () => {
|
||||
if (isPageActive) deletingPhotoId.value = "";
|
||||
}
|
||||
};
|
||||
const requestBatchDelete = () => {
|
||||
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
batchDeleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeBatchDeleteConfirmation = () => {
|
||||
if (!batchDeleting.value) batchDeleteConfirmationVisible.value = false;
|
||||
};
|
||||
const deleteSelectedPhotos = async () => {
|
||||
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
|
||||
const photoIds = selectedPhotoIds.value.slice();
|
||||
batchDeleting.value = true;
|
||||
deleteError.value = "";
|
||||
deleteNotice.value = "";
|
||||
let deletedCount = 0;
|
||||
let failedRequest = null;
|
||||
try {
|
||||
for (const photoId of photoIds) {
|
||||
const photo = photos.value.find((item) => item.id === photoId);
|
||||
if (!photo?.canDelete) continue;
|
||||
try {
|
||||
await familyMediaApi.deleteAlbumPhoto(
|
||||
genealogyId.value,
|
||||
albumId.value,
|
||||
photoId,
|
||||
{ requestController: albumPhotoDeleteController },
|
||||
);
|
||||
} catch (error) {
|
||||
failedRequest = error;
|
||||
break;
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
deletedCount += 1;
|
||||
photos.value = photos.value.filter((item) => item.id !== photoId);
|
||||
selectedPhotoIds.value = selectedPhotoIds.value.filter(
|
||||
(selectedPhotoId) => selectedPhotoId !== photoId,
|
||||
);
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
batchDeleteConfirmationVisible.value = false;
|
||||
if (failedRequest && !isRequestCancelled(failedRequest)) {
|
||||
const failureCopy = getRequestErrorMessage(
|
||||
failedRequest,
|
||||
"剩余照片删除失败,请稍后重试。",
|
||||
);
|
||||
deleteError.value = deletedCount
|
||||
? `已删除 ${deletedCount} 张;${failureCopy}`
|
||||
: failureCopy;
|
||||
} else if (deletedCount) {
|
||||
deleteNotice.value = `已删除 ${deletedCount} 张照片。`;
|
||||
}
|
||||
if (!photos.value.length) {
|
||||
albumPhotoListState.value = "empty";
|
||||
resetPhotoSelection();
|
||||
} else if (!selectedPhotoIds.value.length) {
|
||||
selectionMode.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) batchDeleting.value = false;
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
@@ -176,6 +386,7 @@ onUnload(() => {
|
||||
albumPhotoListController.abort();
|
||||
albumPhotoDeleteController.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -196,6 +407,7 @@ onUnload(() => {
|
||||
.album-state-card,
|
||||
.photo-card {
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.album-state-card {
|
||||
width: 100%;
|
||||
@@ -225,17 +437,80 @@ onUnload(() => {
|
||||
.photo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.photo-management {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba($paper, 0.9);
|
||||
}
|
||||
.photo-management__summary {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.photo-management__summary text {
|
||||
display: block;
|
||||
}
|
||||
.photo-management__summary text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.photo-management__summary text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.photo-management__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.photo-card {
|
||||
position: relative;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.photo-card--selected {
|
||||
border-color: rgba($brand-red, 0.62);
|
||||
box-shadow: inset 0 0 0 2rpx rgba($brand-red, 0.12);
|
||||
}
|
||||
.photo-card__selection {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 42rpx;
|
||||
right: 46rpx;
|
||||
min-width: 104rpx;
|
||||
min-height: 64rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 18rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.48);
|
||||
border-radius: 32rpx;
|
||||
background: rgba($paper, 0.94);
|
||||
color: $brand-red;
|
||||
text-align: center;
|
||||
line-height: 62rpx;
|
||||
}
|
||||
.photo-card--selected .photo-card__selection {
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
}
|
||||
.photo-list__notice,
|
||||
.photo-list__error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.photo-list__notice {
|
||||
color: #426b58;
|
||||
}
|
||||
.photo-list__error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.photo-card__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -248,19 +523,46 @@ onUnload(() => {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.photo-card text {
|
||||
.photo-card__title,
|
||||
.photo-card__copy,
|
||||
.photo-card__meta {
|
||||
display: block;
|
||||
}
|
||||
.photo-card text:first-child {
|
||||
.photo-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.photo-card text:not(:first-child) {
|
||||
.photo-card__copy,
|
||||
.photo-card__meta {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.photo-card .photo-card__selection text {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 700;
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.photo-management {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.photo-management__actions,
|
||||
.photo-management > .app-button {
|
||||
width: 100%;
|
||||
}
|
||||
.photo-management__actions .app-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
<text>{{ item.photoCount }} 张照片</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<view
|
||||
v-if="item.canEdit || item.canDelete"
|
||||
class="album-card__actions"
|
||||
@@ -118,9 +119,9 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这个相册?"
|
||||
message="相册中的照片也可能无法恢复,请确认已另行保存。"
|
||||
confirm-text="确认删除"
|
||||
title="将这个相册移至回收站?"
|
||||
message="相册和其中照片将不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留相册"
|
||||
show-cancel
|
||||
@confirm="deleteAlbum"
|
||||
@@ -143,6 +144,7 @@ import {
|
||||
} 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 { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
@@ -387,11 +389,12 @@ onUnload(() => {
|
||||
z-index: 1;
|
||||
}
|
||||
.album-list-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.album-state-card,
|
||||
.album-card {
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.album-state-card {
|
||||
min-height: 340rpx;
|
||||
@@ -434,7 +437,7 @@ onUnload(() => {
|
||||
.album-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.album-card {
|
||||
padding: 28rpx 30rpx;
|
||||
@@ -477,7 +480,7 @@ onUnload(() => {
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.album-dialog-field input {
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
><PageHeader title="谱文详情" custom-back @back="requestBack"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
@@ -12,6 +12,15 @@
|
||||
description="请稍候,正在同步谱文正文。"
|
||||
/>
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<image
|
||||
v-if="article.coverFile?.accessUrl"
|
||||
class="article-card__cover"
|
||||
:src="article.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
role="button"
|
||||
aria-label="查看谱文封面"
|
||||
@click="previewCover"
|
||||
/>
|
||||
<text v-if="article.category" class="article-card__category">{{
|
||||
article.category
|
||||
}}</text>
|
||||
@@ -27,13 +36,17 @@
|
||||
<text>这篇谱文已设置内容密码</text>
|
||||
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
|
||||
<button class="article-lock-card__recovery" @click="passwordRecoveryVisible = true">忘记内容密码?</button>
|
||||
<text v-if="protectionError">{{ protectionError }}</text>
|
||||
</view>
|
||||
<text class="article-card__content">{{
|
||||
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
|
||||
}}</text>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
<view v-if="article.canEdit || article.canDelete" class="article-card__actions">
|
||||
<view
|
||||
v-if="article.canEdit || article.canDelete || article.canManageProtection"
|
||||
class="article-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
v-if="article.canEdit && article.content"
|
||||
compact
|
||||
@@ -75,9 +88,9 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这篇谱文?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这篇谱文移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留谱文"
|
||||
show-cancel
|
||||
@confirm="deleteArticle"
|
||||
@@ -98,15 +111,25 @@
|
||||
<input v-if="protectionMode === 'set'" v-model="protectionPassword" class="protection-dialog-input" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||
<text v-if="protectionError" class="article-card__error">{{ protectionError }}</text>
|
||||
</AppDialog>
|
||||
<ContentPasswordRecoveryDialog
|
||||
:visible="passwordRecoveryVisible"
|
||||
:genealogy-id="genealogyId"
|
||||
resource-type="ARTICLE"
|
||||
:resource-id="articleId"
|
||||
@close="passwordRecoveryVisible = false"
|
||||
@complete="completePasswordRecovery"
|
||||
@busy-change="passwordRecoveryBusy = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
@@ -115,7 +138,7 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
@@ -130,6 +153,8 @@ const protectionError = ref("");
|
||||
const protectionDialogVisible = ref(false);
|
||||
const protectionMode = ref("set");
|
||||
const protectionSubmitting = ref(false);
|
||||
const passwordRecoveryVisible = ref(false);
|
||||
const passwordRecoveryBusy = ref(false);
|
||||
const articleReadController = createRequestController();
|
||||
const articleProtectionController = createRequestController();
|
||||
const articleDeleteController = createRequestController();
|
||||
@@ -140,6 +165,11 @@ const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const previewCover = () => {
|
||||
const url = article.value?.coverFile?.accessUrl;
|
||||
if (!url || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: url, urls: [url] });
|
||||
};
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
@@ -241,6 +271,10 @@ const unlockArticle = async () => {
|
||||
protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const completePasswordRecovery = (newPassword) => {
|
||||
protectionPassword.value = newPassword;
|
||||
protectionError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||||
};
|
||||
const openProtectionDialog = (mode) => {
|
||||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
protectionMode.value = mode;
|
||||
@@ -305,6 +339,23 @@ const editArticle = () =>
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (deleting.value || protectionSubmitting.value || passwordRecoveryBusy.value) return true;
|
||||
if (passwordRecoveryVisible.value) {
|
||||
passwordRecoveryVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (protectionDialogVisible.value) {
|
||||
closeProtectionDialog();
|
||||
return true;
|
||||
}
|
||||
if (deleteConfirmationVisible.value) {
|
||||
closeDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
return backToArticles();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
const deleteArticle = async () => {
|
||||
if (!article.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
@@ -352,12 +403,13 @@ const deleteArticle = async () => {
|
||||
}
|
||||
.article-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.article-card,
|
||||
.article-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.article-card {
|
||||
margin-top: 18rpx;
|
||||
@@ -383,6 +435,7 @@ const deleteArticle = async () => {
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.article-card__meta {
|
||||
margin-top: 16rpx;
|
||||
@@ -404,6 +457,7 @@ const deleteArticle = async () => {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 1.85;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article-card__views {
|
||||
@@ -412,6 +466,13 @@ const deleteArticle = async () => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
}
|
||||
.article-card__cover {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
margin-bottom: 22rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.article-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -421,8 +482,10 @@ const deleteArticle = async () => {
|
||||
}
|
||||
.article-lock-card { margin: 16rpx 0; padding: 24rpx; border: 1rpx solid rgba(159, 23, 15, 0.3); border-radius: 10rpx; background: rgba(159, 23, 15, 0.05); }
|
||||
.article-lock-card > text { display: block; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.article-lock-card__recovery { min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: #9e251b; font-size: clamp(13px, 22rpx, 16px); }
|
||||
.article-lock-card__recovery::after { border: 0; }
|
||||
.article-lock-card input,
|
||||
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 76rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 80rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.article-card__error {
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<text v-if="coverFileName" class="upload-receipt"
|
||||
>已上传:{{ coverFileName }}</text
|
||||
>
|
||||
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || isSubmitting" @click="clearCover">移除封面</button>
|
||||
<text v-if="uploadError" class="editor-save-error">{{
|
||||
uploadError
|
||||
}}</text>
|
||||
@@ -344,6 +345,13 @@ const uploadCover = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearCover = () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
coverOssId.value = null;
|
||||
coverFileName.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
|
||||
const saveArticle = async () => {
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
@@ -440,6 +448,7 @@ onUnload(() => {
|
||||
.editor-result-card {
|
||||
@include adaptive-family-panel;
|
||||
width: 100%;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.editor-panel__body {
|
||||
padding: 38rpx 34rpx 42rpx;
|
||||
@@ -459,6 +468,7 @@ onUnload(() => {
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.editor-intro {
|
||||
display: block;
|
||||
@@ -555,6 +565,18 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.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; }
|
||||
.editor-placeholder {
|
||||
color: #9e8e79;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
@action="createArticle"
|
||||
/></view>
|
||||
<view class="article-list-content">
|
||||
<view v-if="articleCategoryError" class="article-category-error" role="alert">
|
||||
<text>{{ articleCategoryError }}</text>
|
||||
</view>
|
||||
<view v-if="categoryOptions.length > 1" class="article-filter">
|
||||
<text>文章分类</text>
|
||||
<picker :range="categoryLabels" :value="categoryIndex" @change="selectCategory">
|
||||
@@ -21,6 +24,13 @@
|
||||
class="article-card"
|
||||
@click="openArticle(item)"
|
||||
>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="article-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<text class="article-card__title">{{ item.title }}</text>
|
||||
<text class="article-card__summary">{{
|
||||
item.summary || item.content
|
||||
@@ -59,6 +69,7 @@ import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
@@ -67,6 +78,7 @@ const hasValidContext = ref(false);
|
||||
const listState = ref("loading");
|
||||
const articles = ref([]);
|
||||
const categories = ref([]);
|
||||
const articleCategoryError = ref("");
|
||||
const selectedCategoryId = ref("");
|
||||
const articleListRequestController = createRequestController();
|
||||
const articleCategoryRequestController = createRequestController();
|
||||
@@ -130,13 +142,18 @@ onUnload(() => {
|
||||
articleCategoryRequestController.abort();
|
||||
});
|
||||
const loadArticleCategories = async () => {
|
||||
articleCategoryError.value = "";
|
||||
try {
|
||||
return await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||
requestController: articleCategoryRequestController,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) throw error;
|
||||
return [];
|
||||
articleCategoryError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"谱文分类暂时无法读取,当前仍可查看全部谱文。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const loadArticles = async () => {
|
||||
@@ -152,7 +169,7 @@ const loadArticles = async () => {
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
articles.value = rows;
|
||||
categories.value = categoryRows;
|
||||
if (categoryRows) categories.value = categoryRows;
|
||||
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = "";
|
||||
}
|
||||
@@ -192,11 +209,20 @@ const handleStateAction = () => {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-list-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.article-list-items {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.article-category-error {
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.22);
|
||||
background: rgba(255, 247, 233, 0.94);
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -207,6 +233,8 @@ const handleStateAction = () => {
|
||||
background: rgba(255, 252, 242, 0.92);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
min-height: 80rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.article-filter__value {
|
||||
color: $brand-red;
|
||||
@@ -235,6 +263,14 @@ const handleStateAction = () => {
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 28rpx;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.article-card__cover {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(128, 89, 49, 0.12);
|
||||
}
|
||||
.article-card text {
|
||||
display: block;
|
||||
@@ -244,6 +280,7 @@ const handleStateAction = () => {
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.article-card__summary {
|
||||
display: -webkit-box;
|
||||
@@ -263,6 +300,7 @@ const handleStateAction = () => {
|
||||
}
|
||||
.article-list-state-card {
|
||||
@include adaptive-family-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
margin-top: 30rpx;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
@click="openEditFeed"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="feed.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除动态"
|
||||
@@ -86,9 +87,9 @@
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这条动态?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这条动态移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留动态"
|
||||
show-cancel
|
||||
@confirm="deleteFeed"
|
||||
@@ -313,7 +314,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
}
|
||||
.feed-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.feed-detail-body {
|
||||
margin-top: 18rpx;
|
||||
@@ -322,6 +323,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.feed-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.feed-card {
|
||||
padding: 30rpx;
|
||||
@@ -348,6 +350,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.feed-card__meta {
|
||||
|
||||
@@ -365,14 +365,15 @@ onUnload(() => {
|
||||
}
|
||||
.publish-page__header,
|
||||
.publish-panel {
|
||||
@include adaptive-family-content;
|
||||
z-index: 1;
|
||||
}
|
||||
.publish-panel {
|
||||
@include adaptive-family-content;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 0;
|
||||
margin: 18rpx auto calc(48rpx + env(safe-area-inset-bottom));
|
||||
padding: 9%;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.publish-form > text,
|
||||
.publish-result > text {
|
||||
@@ -421,11 +422,11 @@ onUnload(() => {
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.publish-field input {
|
||||
min-height: 64rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.publish-field__value--readonly {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12rpx;
|
||||
@@ -459,6 +460,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
|
||||
+18
-8
@@ -113,6 +113,7 @@ const shortcuts = [
|
||||
{ key: "albums", label: "相册" },
|
||||
{ key: "rituals", label: "礼仪" },
|
||||
{ key: "memos", label: "备忘" },
|
||||
{ key: "benefactors", label: "家族恩人" },
|
||||
{ key: "people", label: "人物录" },
|
||||
{ key: "gifts", label: "贺礼簿" },
|
||||
{ key: "merits", label: "功德录" },
|
||||
@@ -239,12 +240,20 @@ const openSection = (key) => {
|
||||
albums: "F07",
|
||||
rituals: "R05",
|
||||
memos: "R10",
|
||||
benefactors: "R10",
|
||||
people: "R01",
|
||||
gifts: "R03",
|
||||
merits: "R11",
|
||||
videos: "F10",
|
||||
};
|
||||
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
|
||||
return openPage(
|
||||
routes[key],
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
|
||||
},
|
||||
"F01",
|
||||
);
|
||||
};
|
||||
const handlePrimaryAction = () =>
|
||||
feedState.value === "error" ? loadFeeds() : toPublish();
|
||||
@@ -264,7 +273,7 @@ const handlePrimaryAction = () =>
|
||||
}
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx 190rpx;
|
||||
padding: 24rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.feed-heading text {
|
||||
display: block;
|
||||
@@ -283,8 +292,8 @@ const handlePrimaryAction = () =>
|
||||
.feed-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8rpx;
|
||||
margin-top: 15rpx;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-shortcut {
|
||||
box-sizing: border-box;
|
||||
@@ -312,6 +321,7 @@ const handlePrimaryAction = () =>
|
||||
box-sizing: border-box;
|
||||
margin-top: 16rpx;
|
||||
padding: 30rpx;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.feed-card text,
|
||||
.feed-state-card text {
|
||||
@@ -353,14 +363,14 @@ const handlePrimaryAction = () =>
|
||||
display: block;
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 22rpx auto 0;
|
||||
padding: 0 24rpx;
|
||||
border: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
line-height: 76rpx;
|
||||
line-height: 80rpx;
|
||||
}
|
||||
.feed-more::after {
|
||||
border: 0;
|
||||
@@ -390,12 +400,12 @@ const handlePrimaryAction = () =>
|
||||
@include adaptive-scroll-button(primary);
|
||||
width: 514rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
margin: 19rpx auto;
|
||||
}
|
||||
.feed-action text {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
<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>
|
||||
<video
|
||||
v-else
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
class="video-card__player"
|
||||
/>
|
||||
<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__player {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #1f1b17;
|
||||
}
|
||||
.video-card__cover-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #1f1b17;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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>
|
||||
+532
-18
@@ -6,7 +6,7 @@
|
||||
title="家族视频"
|
||||
:action="pageState === 'list' ? '发布' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@back="requestBack"
|
||||
@action="openPublishForm"
|
||||
/>
|
||||
</view>
|
||||
@@ -64,6 +64,7 @@
|
||||
<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
|
||||
@@ -106,6 +107,7 @@
|
||||
</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>
|
||||
@@ -127,12 +129,25 @@
|
||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||
</view>
|
||||
<view v-else class="video-card-list">
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="上下滑动观看"
|
||||
@click="openVerticalViewer(videos[0])"
|
||||
/>
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<video
|
||||
class="video-card__player"
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
/>
|
||||
<button
|
||||
class="video-card__cover-action"
|
||||
:aria-label="`播放${video.title}`"
|
||||
@click="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
|
||||
@@ -152,6 +167,7 @@
|
||||
@click="openEditVideo(video)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="video.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="deletingVideoId === video.id"
|
||||
@@ -159,6 +175,11 @@
|
||||
@click="requestDeleteVideo(video)"
|
||||
/>
|
||||
</view>
|
||||
<view 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">{{
|
||||
@@ -174,31 +195,125 @@
|
||||
<AppDialog
|
||||
:visible="deleteConfirmVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这段家族视频?"
|
||||
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
|
||||
:confirm-text="deletingVideoId ? '正在删除' : '确认删除'"
|
||||
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 { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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 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 {
|
||||
isImagePickCancelled,
|
||||
@@ -207,12 +322,15 @@ import {
|
||||
pickAndUploadVideo,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.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);
|
||||
@@ -223,20 +341,222 @@ 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 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"
|
||||
? {
|
||||
@@ -264,6 +584,10 @@ onUnload(() => {
|
||||
coverUploadRequestController.abort();
|
||||
videoSaveRequestController.abort();
|
||||
videoDeletionRequestController.abort();
|
||||
videoCommentListController.abort();
|
||||
videoCommentWriteController.abort();
|
||||
videoCommentDeleteController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
const loadVideos = async () => {
|
||||
@@ -292,6 +616,7 @@ const openPublishForm = () => {
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
formBaseline.value = formSnapshot.value;
|
||||
};
|
||||
const openEditVideo = async (video) => {
|
||||
if (
|
||||
@@ -335,6 +660,7 @@ const openEditVideo = async (video) => {
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
formBaseline.value = formSnapshot.value;
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) {
|
||||
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
|
||||
@@ -418,6 +744,11 @@ const selectCover = async () => {
|
||||
if (pageActive) coverUploading.value = false;
|
||||
}
|
||||
};
|
||||
const clearCover = () => {
|
||||
if (coverUploading.value || submitting.value) return;
|
||||
coverReceipt.value = null;
|
||||
coverUploadError.value = "";
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (
|
||||
uploading.value ||
|
||||
@@ -439,7 +770,7 @@ const submitVideo = async () => {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
|
||||
coverOssId: coverReceipt.value?.ossId ?? null,
|
||||
...(editingVideo.value
|
||||
? {
|
||||
durationSeconds: editingVideo.value.durationSeconds,
|
||||
@@ -493,7 +824,50 @@ const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { 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 (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">
|
||||
@@ -510,15 +884,19 @@ const handleStateAction = () => returnToFamily();
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
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;
|
||||
@@ -563,7 +941,7 @@ const handleStateAction = () => returnToFamily();
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.video-field input {
|
||||
min-height: 76rpx;
|
||||
min-height: 80rpx;
|
||||
padding: 0 18rpx;
|
||||
}
|
||||
.video-field textarea {
|
||||
@@ -580,6 +958,7 @@ const handleStateAction = () => returnToFamily();
|
||||
width: 100%;
|
||||
}
|
||||
.upload-button {
|
||||
min-height: 80rpx;
|
||||
margin: 0;
|
||||
padding: 0 26rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
@@ -587,12 +966,24 @@ const handleStateAction = () => returnToFamily();
|
||||
background: #fffaf0;
|
||||
color: #805723;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 64rpx;
|
||||
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;
|
||||
@@ -621,14 +1012,38 @@ const handleStateAction = () => returnToFamily();
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
background: rgba($paper, 0.9);
|
||||
}
|
||||
.video-card__player {
|
||||
.video-card__cover-action {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
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,
|
||||
@@ -640,12 +1055,14 @@ const handleStateAction = () => returnToFamily();
|
||||
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;
|
||||
@@ -657,4 +1074,101 @@ const handleStateAction = () => returnToFamily();
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user