feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+324 -22
View File
@@ -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>