Files
jiapuapp/pages/family/album-detail.vue
T

569 lines
18 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="album-detail-page">
<ModulePageBackground module="family" />
<view class="album-detail-header">
<PageHeader
title="相册详情"
:action="headerAction"
custom-back
@back="requestBack"
@action="handleHeaderAction"
/>
</view>
<view class="album-detail-content">
<view v-if="!valid" class="album-state-card">
<text>暂时无法打开相册</text>
<AppButton block label="返回相册列表" @click="returnToAlbums" />
</view>
<view v-else-if="albumPhotoListState === 'loading'" class="album-state-card">
<AppLoading text="正在读取相册照片" />
</view>
<view v-else-if="albumPhotoListState === 'error'" class="album-state-card">
<text>暂时无法读取相册照片</text>
<AppButton block type="secondary" label="重新加载" @click="loadPhotos" />
</view>
<view v-else-if="albumPhotoListState === 'empty'" class="album-state-card">
<text>还没有照片</text>
<text>添加照片后页面会显示最新相册内容</text>
<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"
: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="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 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>
</view>
</view>
<AppDialog
:visible="deleteConfirmationVisible"
:close-on-mask="false"
eyebrow="删除确认"
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 { 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 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 { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const albumId = ref("");
const photos = ref([]);
const albumPhotoListState = ref("loading");
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;
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();
albumPhotoListState.value = "loading";
try {
const albumPhotos = await familyMediaApi.getAlbumPhotos(genealogyId.value, albumId.value, {
requestController: albumPhotoListController,
});
if (!isPageActive) return;
photos.value = albumPhotos.map((photo) => ({
...photo,
meta: [photo.photographer, photo.shootTime].filter(Boolean).join(" · "),
}));
resetPhotoSelection();
albumPhotoListState.value = photos.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
albumPhotoListState.value = "error";
}
};
const returnToAlbums = () =>
/^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const addPhoto = () =>
valid.value
? openPage(
"F09",
{ genealogyId: genealogyId.value, albumId: albumId.value },
"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 || deleteInProgress.value) return;
deleteError.value = "";
deleteNotice.value = "";
deleteTarget.value = photo;
deleteConfirmationVisible.value = true;
};
const closeDeleteConfirmation = () => {
if (!deletingPhotoId.value) {
deleteConfirmationVisible.value = false;
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;
deletingPhotoId.value = photo.id;
deleteError.value = "";
try {
await familyMediaApi.deleteAlbumPhoto(genealogyId.value, albumId.value, photo.id, {
requestController: albumPhotoDeleteController,
});
if (!isPageActive) return;
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
await loadPhotos();
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
deleteError.value = getRequestErrorMessage(error, "照片删除失败,请稍后重试。");
deleteConfirmationVisible.value = false;
} finally {
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 || "");
if (valid.value) loadPhotos();
});
onShow(() => {
if (valid.value && albumPhotoListState.value !== "loading") loadPhotos();
});
onUnload(() => {
isPageActive = false;
albumPhotoListController.abort();
albumPhotoDeleteController.abort();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.album-detail-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.album-detail-header,
.album-detail-content {
z-index: 1;
}
.album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.album-state-card,
.photo-card {
@include adaptive-family-content;
background-color: rgba($paper, 0.82);
}
.album-state-card {
width: 100%;
min-height: 340rpx;
padding: 84rpx 44rpx 56rpx;
box-sizing: border-box;
text-align: center;
}
.album-state-card text {
display: block;
}
.album-state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 36rpx, 24px);
font-weight: 700;
}
.album-state-card text:nth-child(2) {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.6;
}
.album-state-card .app-button {
margin-top: 34rpx;
}
.photo-list {
display: flex;
flex-direction: column;
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;
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%;
margin-bottom: 20rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.photo-card__actions {
display: flex;
justify-content: flex-end;
margin-top: 16rpx;
}
.photo-card__title,
.photo-card__copy,
.photo-card__meta {
display: block;
}
.photo-card__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.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>