Files
jiapuapp/pages/family/albums.vue
T

527 lines
15 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-list-page">
<ModulePageBackground module="family" />
<view class="album-list-header"
><PageHeader
title="家族相册"
:action="hasValidContext ? '新建' : ''"
custom-back
@back="requestBack"
@action="openCreateDialog"
/></view>
<view class="album-list-content">
<view v-if="!hasValidContext" class="album-state-card"
><text>暂时无法打开相册</text><text>未找到家谱信息请返回后重新进入</text
><AppButton block label="返回上一页" @click="goBack"
/></view>
<view v-else-if="listState === 'loading'" class="album-state-card"
><AppLoading text="正在读取家族相册"
/></view>
<view v-else-if="listState === 'error'" class="album-state-card"
><text>暂时无法读取家族相册</text><text>{{ listError }}</text
><AppButton block type="secondary" label="重新加载" @click="loadAlbums"
/></view>
<view v-else-if="listState === 'empty'" class="album-state-card"
><text>还没有相册</text><text>新建第一本相册整理家族影像</text
><AppButton block label="新建相册" @click="openCreateDialog"
/></view>
<view v-else class="album-list">
<text v-if="deleteError" class="album-list__error">{{ deleteError }}</text>
<view
v-for="item in albums"
:key="item.id"
class="album-card"
@click="openAlbum(item)"
>
<image
v-if="item.coverFile?.accessUrl"
class="album-card__cover"
:src="item.coverFile.accessUrl"
mode="aspectFill"
/>
<text>{{ item.name }}</text>
<text v-if="item.description">{{ item.description }}</text>
<text>{{ item.photoCount }} 张照片</text>
<view
v-if="item.canEdit || item.canDelete"
class="album-card__actions"
@click.stop
>
<AppButton
v-if="item.canEdit"
compact
type="secondary"
label="编辑相册"
@click="openEditDialog(item)"
/>
<AppButton
v-if="item.canDelete"
compact
type="secondary"
label="删除相册"
@click="requestDeleteAlbum(item)"
/>
</view>
</view>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:eyebrow="editingAlbum ? '编辑相册' : '新建相册'"
:title="editingAlbum ? '更新这本家族相册' : '为家人整理一段影像'"
:confirm-text="isSubmitting ? '正在提交' : editingAlbum ? '保存修改' : '提交相册'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="submitAlbum"
@cancel="closeCreateDialog"
>
<view class="album-dialog-field"
><text><text class="required-mark">*</text>相册名称</text
><input
v-model="form.albumName"
placeholder="例如:春节团圆"
@input="submitError = ''"
/></view>
<view class="album-dialog-field"
><text>相册说明</text
><textarea
v-model="form.albumDesc"
auto-height
placeholder="介绍这组影像"
@input="submitError = ''"
/>
</view>
<view class="album-dialog-field album-dialog-field--cover"
><view
><text>封面图片</text
><text class="album-field-hint"
>图片上传成功后会作为相册封面保存</text
></view
><button
class="upload-button"
:disabled="uploading || isSubmitting"
@click="uploadCover"
>
{{ uploading ? "上传中…" : "选择图片" }}</button
><text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
><text v-if="uploadError" class="album-field-error">{{
uploadError
}}</text></view
>
<text v-if="submitError" class="album-field-error">{{
submitError
}}</text>
</AppDialog>
<AppDialog
:visible="deleteConfirmationVisible"
:close-on-mask="false"
eyebrow="删除确认"
title="删除这个相册?"
message="相册中的照片也可能无法恢复,请确认已另行保存。"
confirm-text="确认删除"
cancel-text="保留相册"
show-cancel
@confirm="deleteAlbum"
@cancel="closeDeleteConfirmation"
/>
</view>
</template>
<script setup>
import { computed, reactive, 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 {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
goBack,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const albums = ref([]);
const listState = ref("loading");
const listError = ref("");
const dialogVisible = ref(false);
const form = reactive({ albumName: "", albumDesc: "" });
const editingAlbum = ref(null);
const coverOssId = ref(null);
const coverFileName = ref("");
const submitError = ref("");
const uploadError = ref("");
const uploading = ref(false);
const isSubmitting = ref(false);
const deleteTarget = ref(null);
const deleteConfirmationVisible = ref(false);
const deletingAlbumId = ref("");
const deleteError = ref("");
const albumListController = createRequestController();
const albumCoverUploadController = createRequestController();
const albumSaveController = createRequestController();
const albumDeleteController = createRequestController();
const albumCreateGuard = createNonIdempotentWriteGuard();
let isPageActive = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const loadAlbums = async () => {
if (!hasValidContext.value) return;
albumListController.abort();
listState.value = "loading";
listError.value = "";
try {
const rows = await familyMediaApi.getAlbums(genealogyId.value, {
requestController: albumListController,
});
if (!isPageActive) return;
albums.value = rows;
listState.value = albums.value.length ? "list" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
listState.value = "error";
listError.value = getRequestErrorMessage(error, "请稍后重试。");
}
};
const resetDraft = () => {
Object.assign(form, { albumName: "", albumDesc: "" });
coverOssId.value = null;
coverFileName.value = "";
submitError.value = "";
uploadError.value = "";
editingAlbum.value = null;
};
const openCreateDialog = () => {
if (!hasValidContext.value || isSubmitting.value || uploading.value) return;
resetDraft();
dialogVisible.value = true;
};
const openEditDialog = (album) => {
if (
!album?.canEdit ||
isSubmitting.value ||
uploading.value ||
!Number.isSafeInteger(album.sortOrder) ||
!["0", "1"].includes(album.status)
) {
return;
}
resetDraft();
Object.assign(form, { albumName: album.name, albumDesc: album.description });
coverOssId.value = album.coverFile?.ossId || null;
coverFileName.value = album.coverFile
? album.coverFile.fileName || "当前封面图片"
: "";
editingAlbum.value = {
id: album.id,
sortOrder: album.sortOrder,
status: album.status,
};
dialogVisible.value = true;
};
const closeCreateDialog = () => {
if (!isSubmitting.value && !uploading.value) dialogVisible.value = false;
};
const uploadCover = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: albumCoverUploadController,
});
if (!isPageActive) return;
coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (!isPageActive) return;
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
} finally {
if (isPageActive) uploading.value = false;
}
};
const submitAlbum = async () => {
if (isSubmitting.value || uploading.value) return;
if (!form.albumName.trim()) {
submitError.value = "请填写相册名称";
return;
}
const payload = {
...form,
coverOssId: coverOssId.value,
...(editingAlbum.value
? {
sortOrder: editingAlbum.value.sortOrder,
status: editingAlbum.value.status,
}
: {}),
};
const createAttempt = editingAlbum.value ? null : albumCreateGuard.begin(payload);
if (!editingAlbum.value && createAttempt === null) {
submitError.value =
"上次提交结果暂时无法确认,请先关闭窗口并检查相册列表,避免重复创建。";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
if (editingAlbum.value) {
await familyMediaApi.updateAlbum(
genealogyId.value,
editingAlbum.value.id,
payload,
{ requestController: albumSaveController },
);
} else {
await familyMediaApi.createAlbum(genealogyId.value, payload, {
requestController: albumSaveController,
});
}
if (!isPageActive) return;
dialogVisible.value = false;
await loadAlbums();
} catch (error) {
if (!isPageActive) return;
if (!editingAlbum.value && albumCreateGuard.recordFailure(createAttempt, error)) {
submitError.value =
"提交结果暂时无法确认,请先关闭窗口并检查相册列表,避免重复创建。";
return;
}
if (!isRequestCancelled(error))
submitError.value = getRequestErrorMessage(error, "相册提交失败,请稍后重试。");
} finally {
if (isPageActive) isSubmitting.value = false;
}
};
const openAlbum = (item) =>
openPage("F08", { genealogyId: genealogyId.value, albumId: item.id }, "F07");
const requestDeleteAlbum = (album) => {
if (!album?.canDelete || deletingAlbumId.value) return;
deleteError.value = "";
deleteTarget.value = album;
deleteConfirmationVisible.value = true;
};
const closeDeleteConfirmation = () => {
if (!deletingAlbumId.value) {
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
}
};
const deleteAlbum = async () => {
const album = deleteTarget.value;
if (!album?.canDelete || deletingAlbumId.value) return;
deletingAlbumId.value = album.id;
deleteError.value = "";
try {
await familyMediaApi.deleteAlbum(genealogyId.value, album.id, {
requestController: albumDeleteController,
});
if (!isPageActive) return;
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
await loadAlbums();
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
deleteError.value = getRequestErrorMessage(error, "相册删除失败,请稍后重试。");
deleteConfirmationVisible.value = false;
} finally {
if (isPageActive) deletingAlbumId.value = "";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: dialogVisible.value,
submitting: isSubmitting.value || uploading.value,
"close-transient": closeCreateDialog,
"block-submitting": () => true,
});
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (hasValidContext.value) loadAlbums();
else listState.value = "invalid";
});
onShow(() => {
if (
hasValidContext.value &&
!dialogVisible.value &&
listState.value !== "loading"
)
loadAlbums();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
isPageActive = false;
albumListController.abort();
albumCoverUploadController.abort();
albumSaveController.abort();
albumDeleteController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.album-list-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.album-list-header,
.album-list-content {
z-index: 1;
}
.album-list-content {
padding: 18rpx 24rpx 72rpx;
}
.album-state-card,
.album-card {
@include adaptive-family-content;
}
.album-state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.album-state-card text,
.album-card text {
display: block;
}
.album-card__actions {
display: flex;
gap: 12rpx;
justify-content: flex-end;
margin-top: 14rpx;
}
.album-list__error {
display: block;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.album-state-card text:first-child,
.album-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 35rpx, 24px);
font-weight: 700;
}
.album-state-card text:nth-child(2),
.album-card text:not(:first-child) {
margin-top: 12rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
}
.album-state-card .app-button {
margin-top: 28rpx;
}
.album-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.album-card {
padding: 28rpx 30rpx;
}
.album-card__cover {
display: block;
width: 100%;
height: 240rpx;
margin-bottom: 20rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.album-dialog-field {
width: 100%;
margin: 20rpx 0;
text-align: left;
}
.album-dialog-field > text:first-child,
.album-dialog-field--cover > view > text:first-child {
display: block;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
}
.album-dialog-field input,
.album-dialog-field textarea {
@include adaptive-family-field;
box-sizing: border-box;
display: block;
width: 100%;
margin-top: 10rpx;
padding: 16rpx 22rpx;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.album-dialog-field input {
min-height: 76rpx;
padding-top: 0;
padding-bottom: 0;
}
.album-dialog-field textarea {
min-height: 112rpx;
line-height: 1.55;
}
.album-dialog-field--cover {
display: grid;
gap: 12rpx;
padding: 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.album-field-hint {
display: block;
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.45;
}
.upload-button {
justify-self: start;
min-height: 88rpx;
margin: 0;
padding: 0 20rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.album-field-error {
display: block;
margin-top: 8rpx;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
</style>