feat: migrate app routes and business modules
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-09;用途:上传真实图片并创建相册照片记录。 -->
|
||||
<template>
|
||||
<view class="media-upload-page" :class="`media-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -9,7 +8,7 @@
|
||||
<view v-if="pageState === 'form'" class="media-panel">
|
||||
<text class="media-panel__title">添加一张家族照片</text>
|
||||
<text class="media-panel__note"
|
||||
>请先选择图片。保存时只会提交真实上传回执中的文件标识。</text
|
||||
>请先选择图片。上传成功后,照片才会保存到相册。</text
|
||||
>
|
||||
|
||||
<view class="media-field media-field--upload">
|
||||
@@ -80,16 +79,6 @@
|
||||
}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">排序值</text>
|
||||
<input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
@@ -109,7 +98,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃照片草稿?"
|
||||
message="当前内容尚未保存到服务端,返回后不会保留。"
|
||||
message="照片还没有保存,返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -120,28 +109,30 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
@@ -157,9 +148,11 @@ const form = reactive({
|
||||
photographer: "",
|
||||
shootDate: "",
|
||||
shootClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const controller = createRequestController();
|
||||
const photoUploadController = createRequestController();
|
||||
const photoSaveController = createRequestController();
|
||||
const photoCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const discardVisible = ref(false);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -179,13 +172,13 @@ const shootTime = computed(() =>
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
title: "照片已提交服务端",
|
||||
copy: "服务端已返回成功结果。",
|
||||
title: "照片已添加",
|
||||
copy: "照片已保存到相册。",
|
||||
action: "返回相册",
|
||||
}
|
||||
: {
|
||||
title: "照片入口无效",
|
||||
copy: "没有取得有效家谱或相册标识。",
|
||||
title: "暂时无法添加照片",
|
||||
copy: "未找到家谱或相册信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -201,13 +194,17 @@ const selectPhoto = async () => {
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadImage({ requestController: controller });
|
||||
const uploadedPhoto = await pickAndUploadImage({
|
||||
requestController: photoUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
receipt.value = uploadedPhoto;
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectShootDate = (event) => {
|
||||
@@ -225,26 +222,41 @@ const submitPhoto = async () => {
|
||||
submitError.value = "请先选择并上传照片";
|
||||
return;
|
||||
}
|
||||
const { shootDate, shootClock, ...photoForm } = form;
|
||||
const payload = {
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: receipt.value.ossId,
|
||||
};
|
||||
const createAttempt = photoCreateGuard.begin(payload);
|
||||
if (createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { shootDate, shootClock, ...photoForm } = form;
|
||||
await appApi.createAlbumPhoto(
|
||||
await familyMediaApi.createAlbumPhoto(
|
||||
genealogyId.value,
|
||||
albumId.value,
|
||||
{
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: receipt.value.ossId,
|
||||
},
|
||||
{ requestController: controller },
|
||||
payload,
|
||||
{ requestController: photoSaveController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (photoCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "照片保存失败,请稍后重试";
|
||||
submitError.value = getRequestErrorMessage(error, "照片保存失败,请稍后重试");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,8 +279,10 @@ const requestBack = () =>
|
||||
const handleStateAction = () =>
|
||||
pageState.value === "success" ? returnToAlbum() : goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
photoUploadController.abort();
|
||||
photoSaveController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header">
|
||||
<PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
/>
|
||||
</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">
|
||||
<text v-if="deleteError" class="photo-list__error">{{ deleteError }}</text>
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card">
|
||||
<image
|
||||
class="photo-card__image"
|
||||
:src="item.photoFile.accessUrl"
|
||||
mode="widthFix"
|
||||
role="button"
|
||||
:aria-label="`查看大图:${item.title}`"
|
||||
@click="previewPhoto(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">
|
||||
<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"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { 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, 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 albumPhotoListController = createRequestController();
|
||||
const albumPhotoDeleteController = createRequestController();
|
||||
let isPageActive = true;
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||
);
|
||||
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(" · "),
|
||||
}));
|
||||
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 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 requestDeletePhoto = (photo) => {
|
||||
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = photo;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deletingPhotoId.value) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
};
|
||||
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 = "";
|
||||
}
|
||||
};
|
||||
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();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
.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: 16rpx;
|
||||
}
|
||||
.photo-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.photo-list__error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.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 text {
|
||||
display: block;
|
||||
}
|
||||
.photo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.photo-card text:not(:first-child) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-07;用途:读取并创建当前家谱的相册。 -->
|
||||
<template>
|
||||
<view class="album-list-page">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -12,7 +11,7 @@
|
||||
/></view>
|
||||
<view class="album-list-content">
|
||||
<view v-if="!hasValidContext" class="album-state-card"
|
||||
><text>相册入口无效</text><text>没有取得有效家谱标识。</text
|
||||
><text>暂时无法打开相册</text><text>未找到家谱信息,请返回后重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="goBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="album-state-card"
|
||||
@@ -27,22 +26,50 @@
|
||||
><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)"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.description">{{ item.description }}</text
|
||||
><text>{{ item.photoCount }} 张照片</text></view
|
||||
>
|
||||
<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="新建相册"
|
||||
title="为家人整理一段影像"
|
||||
:confirm-text="isSubmitting ? '正在提交' : '提交相册'"
|
||||
:eyebrow="editingAlbum ? '编辑相册' : '新建相册'"
|
||||
:title="editingAlbum ? '更新这本家族相册' : '为家人整理一段影像'"
|
||||
:confirm-text="isSubmitting ? '正在提交' : editingAlbum ? '保存修改' : '提交相册'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@@ -69,7 +96,7 @@
|
||||
><view
|
||||
><text>封面图片</text
|
||||
><text class="album-field-hint"
|
||||
>选择图片后会取得真实上传回执,并作为封面关联。</text
|
||||
>图片上传成功后,会作为相册封面保存。</text
|
||||
></view
|
||||
><button
|
||||
class="upload-button"
|
||||
@@ -83,104 +110,129 @@
|
||||
uploadError
|
||||
}}</text></view
|
||||
>
|
||||
<view class="album-dialog-field"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="submitError = ''"
|
||||
/></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, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
|
||||
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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} 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: "", sortOrder: "" });
|
||||
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 controller = createRequestController();
|
||||
let active = true;
|
||||
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 text = (value) =>
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? String(value).trim()
|
||||
: "";
|
||||
const toAlbum = (item) => {
|
||||
const id = text(item?.albumId);
|
||||
if (!/^[1-9]\d*$/.test(id)) return null;
|
||||
return {
|
||||
id,
|
||||
name: text(item.albumName) || "未命名相册",
|
||||
description: text(item.albumDesc),
|
||||
photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0,
|
||||
};
|
||||
};
|
||||
const loadAlbums = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
controller.abort();
|
||||
albumListController.abort();
|
||||
listState.value = "loading";
|
||||
listError.value = "";
|
||||
try {
|
||||
const rows = await appApi.getAlbums(genealogyId.value, {
|
||||
requestController: controller,
|
||||
const rows = await familyMediaApi.getAlbums(genealogyId.value, {
|
||||
requestController: albumListController,
|
||||
});
|
||||
if (!active) return;
|
||||
albums.value = rows.map(toAlbum).filter(Boolean);
|
||||
if (!isPageActive) return;
|
||||
albums.value = rows;
|
||||
listState.value = albums.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
listState.value = "error";
|
||||
listError.value = error?.message || "请稍后重试。";
|
||||
listError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
}
|
||||
};
|
||||
const resetDraft = () => {
|
||||
Object.assign(form, { albumName: "", albumDesc: "", sortOrder: "" });
|
||||
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;
|
||||
};
|
||||
@@ -189,14 +241,18 @@ const uploadCover = async () => {
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
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 = error?.message || "封面图片上传失败,请稍后重试。";
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (isPageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitAlbum = async () => {
|
||||
@@ -205,25 +261,89 @@ const submitAlbum = async () => {
|
||||
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 {
|
||||
await appApi.createAlbum(
|
||||
genealogyId.value,
|
||||
{ ...form, coverOssId: coverOssId.value },
|
||||
{ requestController: controller },
|
||||
);
|
||||
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 = error?.message || "相册提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "相册提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
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,
|
||||
@@ -245,9 +365,12 @@ onShow(() => {
|
||||
loadAlbums();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
albumListController.abort();
|
||||
albumCoverUploadController.abort();
|
||||
albumSaveController.abort();
|
||||
albumDeleteController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -279,6 +402,18 @@ onUnmounted(() => {
|
||||
.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;
|
||||
@@ -304,6 +439,14 @@ onUnmounted(() => {
|
||||
.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;
|
||||
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
<AppLoading
|
||||
v-if="articleState === 'loading'"
|
||||
text="正在读取谱文"
|
||||
description="请稍候,正在同步谱文正文。"
|
||||
/>
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<text v-if="article.category" class="article-card__category">{{
|
||||
article.category
|
||||
}}</text>
|
||||
<text class="article-card__title">{{ article.title }}</text>
|
||||
<text class="article-card__meta"
|
||||
>{{ article.author }} · {{ article.time || "未标注时间" }}</text
|
||||
>
|
||||
<text v-if="article.summary" class="article-card__summary">{{
|
||||
article.summary
|
||||
}}</text>
|
||||
<view class="article-card__divider" />
|
||||
<view v-if="article.contentProtected && !article.contentUnlocked" class="article-lock-card">
|
||||
<text>这篇谱文已设置内容密码</text>
|
||||
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
|
||||
<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">
|
||||
<AppButton
|
||||
v-if="article.canEdit && article.content"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑谱文"
|
||||
@click="editArticle"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="article.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除谱文"
|
||||
@click="requestDeleteArticle"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="article.canManageProtection"
|
||||
compact
|
||||
type="secondary"
|
||||
:label="article.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||
@click="openProtectionDialog('set')"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="article.canManageProtection && article.contentProtected"
|
||||
compact
|
||||
type="secondary"
|
||||
label="关闭内容密码"
|
||||
@click="openProtectionDialog('disable')"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="deleteError" class="article-card__error">{{ deleteError }}</text>
|
||||
</view>
|
||||
<view v-else class="article-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这篇谱文?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留谱文"
|
||||
show-cancel
|
||||
@confirm="deleteArticle"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="protectionDialogVisible"
|
||||
eyebrow="内容密码"
|
||||
:title="protectionMode === 'disable' ? '关闭内容密码?' : article?.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||
:message="protectionMode === 'disable' ? '关闭后,有权查看谱文的成员无需密码即可阅读正文。' : '设置8至128位密码,之后阅读正文需要先验证。'"
|
||||
:confirm-text="protectionSubmitting ? '正在保存' : protectionMode === 'disable' ? '确认关闭' : '确认保存'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmProtectionChange"
|
||||
@cancel="closeProtectionDialog"
|
||||
>
|
||||
<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>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { 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 { 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";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const articleState = ref("loading");
|
||||
const article = ref(null);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const articleAccessToken = ref("");
|
||||
const protectionPassword = ref("");
|
||||
const protectionError = ref("");
|
||||
const protectionDialogVisible = ref(false);
|
||||
const protectionMode = ref("set");
|
||||
const protectionSubmitting = ref(false);
|
||||
const articleReadController = createRequestController();
|
||||
const articleProtectionController = createRequestController();
|
||||
const articleDeleteController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "暂时无法打开谱文",
|
||||
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (articleState.value === "missing") {
|
||||
return {
|
||||
title: "该谱文已不存在",
|
||||
copy: "它可能已被作者删除,或你暂时无法查看。",
|
||||
action: "返回谱文列表",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
|
||||
const loadArticle = async (accessToken = articleAccessToken.value) => {
|
||||
if (!hasValidContext.value) {
|
||||
articleState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
articleState.value = "loading";
|
||||
try {
|
||||
const current = await familyArticleApi.getArticleDetail(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
accessToken,
|
||||
{ requestController: articleReadController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
article.value = current;
|
||||
articleState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
articleState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
articleId.value = String(query?.articleId || "");
|
||||
void loadArticle();
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void loadArticle();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleReadController.abort();
|
||||
articleProtectionController.abort();
|
||||
articleDeleteController.abort();
|
||||
});
|
||||
|
||||
const backToArticles = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () =>
|
||||
articleState.value === "error" ? loadArticle() : backToArticles();
|
||||
const requestDeleteArticle = () => {
|
||||
if (!article.value?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const unlockArticle = async () => {
|
||||
if (protectionSubmitting.value) return;
|
||||
if (protectionPassword.value.length < 8 || protectionPassword.value.length > 128) {
|
||||
protectionError.value = "请输入8至128位内容密码。";
|
||||
return;
|
||||
}
|
||||
protectionSubmitting.value = true;
|
||||
protectionError.value = "";
|
||||
try {
|
||||
const grant = await familyArticleApi.unlockArticle(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
protectionPassword.value,
|
||||
{ requestController: articleProtectionController },
|
||||
);
|
||||
articleAccessToken.value = grant.accessToken;
|
||||
protectionPassword.value = "";
|
||||
await loadArticle(grant.accessToken);
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) protectionError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
|
||||
} finally {
|
||||
protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const openProtectionDialog = (mode) => {
|
||||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
protectionMode.value = mode;
|
||||
protectionPassword.value = "";
|
||||
protectionError.value = "";
|
||||
protectionDialogVisible.value = true;
|
||||
};
|
||||
const closeProtectionDialog = () => {
|
||||
if (protectionSubmitting.value) return;
|
||||
protectionDialogVisible.value = false;
|
||||
protectionPassword.value = "";
|
||||
protectionError.value = "";
|
||||
};
|
||||
const confirmProtectionChange = async () => {
|
||||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||
if (protectionMode.value === "set" && (protectionPassword.value.length < 8 || protectionPassword.value.length > 128)) {
|
||||
protectionError.value = "请输入8至128位内容密码。";
|
||||
return;
|
||||
}
|
||||
protectionSubmitting.value = true;
|
||||
protectionError.value = "";
|
||||
try {
|
||||
if (protectionMode.value === "disable") {
|
||||
await familyArticleApi.disableArticlePassword(genealogyId.value, articleId.value, {
|
||||
requestController: articleProtectionController,
|
||||
});
|
||||
} else {
|
||||
await familyArticleApi.setArticlePassword(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
protectionPassword.value,
|
||||
{ requestController: articleProtectionController },
|
||||
);
|
||||
}
|
||||
if (!pageActive) return;
|
||||
articleAccessToken.value = "";
|
||||
protectionDialogVisible.value = false;
|
||||
protectionPassword.value = "";
|
||||
await loadArticle("");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
protectionError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"内容密码设置没有保存,请稍后重试。",
|
||||
);
|
||||
} finally {
|
||||
if (pageActive) protectionSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const editArticle = () =>
|
||||
article.value?.canEdit && article.value.content && !deleting.value
|
||||
? openPage(
|
||||
"F06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
articleId: articleId.value,
|
||||
mode: "edit",
|
||||
},
|
||||
"F05",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||
};
|
||||
const deleteArticle = async () => {
|
||||
if (!article.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await familyArticleApi.deleteArticle(genealogyId.value, articleId.value, {
|
||||
requestController: articleDeleteController,
|
||||
});
|
||||
deletionCommitted = true;
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
article.value = null;
|
||||
articleState.value = "missing";
|
||||
await backToArticles();
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (deletionCommitted) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
article.value = null;
|
||||
articleState.value = "missing";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "谱文删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.article-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.article-detail-header,
|
||||
.article-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.article-card,
|
||||
.article-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.article-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 30rpx;
|
||||
}
|
||||
.article-card__category,
|
||||
.article-card__title,
|
||||
.article-card__meta,
|
||||
.article-card__summary,
|
||||
.article-card__content,
|
||||
.article-card__views {
|
||||
display: block;
|
||||
}
|
||||
.article-card__category {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-card__title {
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
}
|
||||
.article-card__meta {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.article-card__summary {
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-card__divider {
|
||||
height: 1rpx;
|
||||
margin: 26rpx 0;
|
||||
background: rgba(128, 89, 49, 0.22);
|
||||
}
|
||||
.article-card__content {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article-card__views {
|
||||
margin-top: 30rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
}
|
||||
.article-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.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 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); }
|
||||
.article-card__error {
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
.article-state-card {
|
||||
min-height: 350rpx;
|
||||
margin-top: 36rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.article-state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.article-state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.article-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-06;用途:按 Apifox 的完整 AppArticleBody 创建谱文。 -->
|
||||
<template>
|
||||
<view
|
||||
class="article-editor-page"
|
||||
@@ -6,22 +5,32 @@
|
||||
>
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-editor-page__header"
|
||||
><PageHeader title="新建谱文" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑谱文' : '新建谱文'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="article-editor-content">
|
||||
<view v-if="editorState === 'form'" class="editor-panel">
|
||||
<AppLoading v-if="editorState === 'loading'" text="正在读取谱文" />
|
||||
<view v-else-if="editorState === 'form'" class="editor-panel">
|
||||
<view class="editor-panel__body">
|
||||
<text class="editor-eyebrow">服务端创建</text>
|
||||
<text class="editor-title">把值得传承的故事写下来</text>
|
||||
<text class="editor-eyebrow">{{ isEdit ? '编辑谱文' : '新建谱文' }}</text>
|
||||
<text class="editor-title">{{ isEdit ? '更新这篇传承故事' : '把值得传承的故事写下来' }}</text>
|
||||
<text class="editor-intro"
|
||||
>除标题和正文外,其余字段均可按需填写;留空时不提交该字段。</text
|
||||
>{{ isEdit ? '未重新选择封面时,原封面会保留。' : '除标题和正文外,其他内容可按需填写。' }}</text
|
||||
>
|
||||
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">文章分类</text>
|
||||
<view class="editor-control editor-control--unavailable"
|
||||
><text>服务端暂未提供可选择的分类项</text></view
|
||||
<picker
|
||||
:range="categoryOptionLabels"
|
||||
:value="categoryOptionIndex"
|
||||
:disabled="categoryOptionsState !== 'ready' || !categoryOptions.length"
|
||||
@change="selectCategory"
|
||||
>
|
||||
<view class="editor-control editor-control--picker" :class="{ 'editor-control--unavailable': categoryOptionsState !== 'ready' }">
|
||||
<text>{{ categoryOptionLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="categoryOptionsState === 'loading'" class="editor-field__hint">正在获取文章分类</text>
|
||||
<text v-else-if="categoryOptionsState === 'error'" class="editor-field__hint">暂时无法获取文章分类,可不选分类继续填写。</text>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label"
|
||||
@@ -51,7 +60,7 @@
|
||||
<view>
|
||||
<text class="editor-field__label">封面图片</text>
|
||||
<text class="editor-field__hint"
|
||||
>选择图片后会取得真实上传回执,并作为封面关联。</text
|
||||
>图片上传成功后,会作为谱文封面保存。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -94,25 +103,14 @@
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">排序值</text>
|
||||
<view class="editor-control"
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
placeholder-class="editor-placeholder"
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
</view>
|
||||
<text v-if="submitError" class="editor-save-error">{{
|
||||
submitError
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在提交' : '提交谱文'"
|
||||
:label="isSubmitting ? '正在提交' : isEdit ? '保存修改' : '提交谱文'"
|
||||
:disabled="isSubmitting || uploading"
|
||||
@click="submit"
|
||||
@click="saveArticle"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -133,7 +131,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃谱文草稿?"
|
||||
message="当前内容尚未提交服务器,确认返回后不会保留。"
|
||||
message="谱文还没有保存,确认返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
@@ -144,31 +142,36 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} 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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const editorState = ref("form");
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const mode = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
@@ -176,34 +179,64 @@ const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const categoryOptionsState = ref("loading");
|
||||
const categoryOptions = ref([]);
|
||||
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
|
||||
const formBaseline = ref("");
|
||||
const form = reactive({
|
||||
categoryId: "",
|
||||
articleTitle: "",
|
||||
articleSummary: "",
|
||||
articleContent: "",
|
||||
authorName: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() =>
|
||||
Boolean(
|
||||
Object.values(form).some((value) => value.trim()) || coverOssId.value,
|
||||
),
|
||||
const articleDetailController = createRequestController();
|
||||
const articleCategoryController = createRequestController();
|
||||
const articleCoverUploadController = createRequestController();
|
||||
const articleSaveController = createRequestController();
|
||||
const articleCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
|
||||
const categoryOptionIndex = computed(() => {
|
||||
const index = categoryOptions.value.findIndex((item) => item.id === form.categoryId);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
|
||||
const isEdit = computed(() => mode.value === "edit");
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
|
||||
);
|
||||
const isDirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Boolean(Object.values(form).some((value) => value.trim()) || coverOssId.value),
|
||||
);
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) &&
|
||||
(mode.value === "create" || (mode.value === "edit" && /^[1-9]\d*$/.test(articleId.value))),
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "success"
|
||||
? {
|
||||
eyebrow: "服务端已接受",
|
||||
title: "谱文已提交",
|
||||
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
|
||||
action: "返回谱文列表",
|
||||
eyebrow: "保存成功",
|
||||
title: isEdit.value ? "谱文已更新" : "谱文已提交",
|
||||
copy: "已保存,返回后会显示最新内容。",
|
||||
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "无法创建谱文",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
: editorState.value === "error"
|
||||
? {
|
||||
eyebrow: "谱文暂不可编辑",
|
||||
title: "这篇谱文暂时无法编辑",
|
||||
copy: submitError.value || "请返回后重新查看。",
|
||||
action: "返回谱文详情",
|
||||
}
|
||||
: {
|
||||
eyebrow: "暂时无法打开谱文",
|
||||
title: "无法编辑谱文",
|
||||
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -214,28 +247,104 @@ const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
articleId.value = String(query?.articleId || "");
|
||||
mode.value = String(query?.mode || "");
|
||||
if (!hasValidContext.value) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
void initializeEditor();
|
||||
});
|
||||
|
||||
const initializeEditor = async () => {
|
||||
editorState.value = "loading";
|
||||
try {
|
||||
await loadArticleCategories();
|
||||
if (isEdit.value) await loadArticleForEdit();
|
||||
if (!pageActive) return;
|
||||
if (editorState.value === "loading") editorState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = getRequestErrorMessage(error, "谱文详情暂时无法读取,请稍后重试。");
|
||||
editorState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadArticleCategories = async () => {
|
||||
categoryOptionsState.value = "loading";
|
||||
try {
|
||||
const categories = await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||
requestController: articleCategoryController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
categoryOptions.value = categories.filter((item) => item.enabled);
|
||||
categoryOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
categoryOptions.value = [];
|
||||
categoryOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadArticleForEdit = async () => {
|
||||
const article = await familyArticleApi.getArticleDetail(genealogyId.value, articleId.value, "", {
|
||||
requestController: articleDetailController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (!article.canEdit) {
|
||||
throw new Error("你暂时不能编辑这篇谱文。");
|
||||
}
|
||||
if (!article.content || !["0", "1"].includes(article.status) || !Number.isSafeInteger(article.sortOrder)) {
|
||||
throw new Error("这篇谱文的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
if (
|
||||
article.categoryId &&
|
||||
!categoryOptions.value.some((item) => item.id === article.categoryId)
|
||||
) {
|
||||
throw new Error("这篇谱文暂时无法编辑,请稍后再试。");
|
||||
}
|
||||
Object.assign(form, {
|
||||
categoryId: article.categoryId || "",
|
||||
articleTitle: article.title,
|
||||
articleSummary: article.summary,
|
||||
articleContent: article.content,
|
||||
authorName: article.authorName,
|
||||
});
|
||||
coverOssId.value = article.coverFile?.ossId || null;
|
||||
coverFileName.value = article.coverFile
|
||||
? article.coverFile.fileName || "当前封面图片"
|
||||
: "";
|
||||
preservedUpdateFields.value = {
|
||||
sortOrder: article.sortOrder,
|
||||
status: article.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
};
|
||||
const selectCategory = (event) => {
|
||||
const index = Number(event.detail.value);
|
||||
form.categoryId = index > 0 ? categoryOptions.value[index - 1]?.id || "" : "";
|
||||
submitError.value = "";
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: articleCoverUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const saveArticle = async () => {
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
submitError.value = !form.articleTitle.trim()
|
||||
@@ -243,41 +352,71 @@ const submit = async () => {
|
||||
: "请填写正文内容";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||
};
|
||||
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
|
||||
if (!isEdit.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createArticle(
|
||||
genealogyId.value,
|
||||
{
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
if (isEdit.value) {
|
||||
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
|
||||
requestController: articleSaveController,
|
||||
});
|
||||
} else {
|
||||
await familyArticleApi.createArticle(genealogyId.value, payload, {
|
||||
requestController: articleSaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isEdit.value && articleCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "谱文提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "谱文提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
editorState.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
});
|
||||
const handleResultAction = () =>
|
||||
editorState.value === "success"
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
? isEdit.value
|
||||
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||
: returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: isEdit.value
|
||||
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleDetailController.abort();
|
||||
articleCategoryController.abort();
|
||||
articleCoverUploadController.abort();
|
||||
articleSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-04;用途:读取并展示当前家谱的谱文列表。 -->
|
||||
<template>
|
||||
<view class="article-list-page" :class="`article-list-state--${listState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -9,9 +8,15 @@
|
||||
@action="createArticle"
|
||||
/></view>
|
||||
<view class="article-list-content">
|
||||
<view v-if="categoryOptions.length > 1" class="article-filter">
|
||||
<text>文章分类</text>
|
||||
<picker :range="categoryLabels" :value="categoryIndex" @change="selectCategory">
|
||||
<view class="article-filter__value">{{ selectedCategoryLabel }} ›</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view v-if="listState === 'list'" class="article-list-items">
|
||||
<view
|
||||
v-for="item in articles"
|
||||
v-for="item in filteredArticles"
|
||||
:key="item.id"
|
||||
class="article-card"
|
||||
@click="openArticle(item)"
|
||||
@@ -21,10 +26,14 @@
|
||||
item.summary || item.content
|
||||
}}</text>
|
||||
<text class="article-card__meta"
|
||||
>{{ item.author || "家族成员" }} ·
|
||||
>{{ item.category ? `${item.category} · ` : "" }}{{ item.author || "家族成员" }} ·
|
||||
{{ item.time || "未标注时间" }}</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="!filteredArticles.length" class="article-filter-empty">
|
||||
<text>这个分类下还没有谱文</text>
|
||||
<text>可以切换其他分类查看。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="article-list-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
@@ -42,17 +51,41 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi } from "@/utils/api.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const listState = ref("loading");
|
||||
const articles = ref([]);
|
||||
const categories = ref([]);
|
||||
const selectedCategoryId = ref("");
|
||||
const articleListRequestController = createRequestController();
|
||||
const articleCategoryRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const categoryOptions = computed(() => [
|
||||
{ id: "", name: "全部分类" },
|
||||
...categories.value.filter((item) => item.enabled),
|
||||
]);
|
||||
const categoryLabels = computed(() => categoryOptions.value.map((item) => item.name));
|
||||
const categoryIndex = computed(() =>
|
||||
Math.max(0, categoryOptions.value.findIndex((item) => item.id === selectedCategoryId.value)),
|
||||
);
|
||||
const selectedCategoryLabel = computed(() => categoryOptions.value[categoryIndex.value]?.name || "全部分类");
|
||||
const filteredArticles = computed(() =>
|
||||
selectedCategoryId.value
|
||||
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
|
||||
: articles.value,
|
||||
);
|
||||
const stateCopy = computed(() =>
|
||||
hasValidContext.value
|
||||
? listState.value === "empty"
|
||||
@@ -73,8 +106,8 @@ const stateCopy = computed(() =>
|
||||
action: "重新读取",
|
||||
}
|
||||
: {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会展示其他家谱内容。",
|
||||
title: "暂时无法打开谱文",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -85,18 +118,53 @@ onLoad((query) => {
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void loadArticles();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleListRequestController.abort();
|
||||
articleCategoryRequestController.abort();
|
||||
});
|
||||
const loadArticleCategories = async () => {
|
||||
try {
|
||||
return await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||
requestController: articleCategoryRequestController,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) throw error;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const loadArticles = async () => {
|
||||
articleListRequestController.abort();
|
||||
articleCategoryRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getArticles(genealogyId.value);
|
||||
const [rows, categoryRows] = await Promise.all([
|
||||
familyArticleApi.getArticles(genealogyId.value, {
|
||||
requestController: articleListRequestController,
|
||||
}),
|
||||
loadArticleCategories(),
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
articles.value = rows;
|
||||
categories.value = categoryRows;
|
||||
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = "";
|
||||
}
|
||||
listState.value = articles.value.length ? "list" : "empty";
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const selectCategory = (event) => {
|
||||
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
|
||||
};
|
||||
const createArticle = () =>
|
||||
hasValidContext.value
|
||||
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
|
||||
@@ -129,6 +197,35 @@ const handleStateAction = () => {
|
||||
.article-list-items {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.article-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20rpx;
|
||||
padding: 22rpx 26rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.18);
|
||||
background: rgba(255, 252, 242, 0.92);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
}
|
||||
.article-filter__value {
|
||||
color: $brand-red;
|
||||
}
|
||||
.article-filter-empty {
|
||||
padding: 64rpx 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.article-filter-empty text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 24rpx, 17px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.article-filter-empty text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 30rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-card {
|
||||
@include adaptive-family-content;
|
||||
display: flex;
|
||||
@@ -1,232 +0,0 @@
|
||||
<!-- 页面编号:F-05;用途:谱文详情。 -->
|
||||
<template>
|
||||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
<AppLoading
|
||||
v-if="articleState === 'loading'"
|
||||
text="正在读取谱文"
|
||||
description="请稍候,正在同步谱文正文。"
|
||||
/>
|
||||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||
<text v-if="article.category" class="article-card__category">{{
|
||||
article.category
|
||||
}}</text>
|
||||
<text class="article-card__title">{{ article.title }}</text>
|
||||
<text class="article-card__meta"
|
||||
>{{ article.author }} · {{ article.time || "未标注时间" }}</text
|
||||
>
|
||||
<text v-if="article.summary" class="article-card__summary">{{
|
||||
article.summary
|
||||
}}</text>
|
||||
<view class="article-card__divider" />
|
||||
<text class="article-card__content">{{
|
||||
article.content || "作者暂未填写正文。"
|
||||
}}</text>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
</view>
|
||||
<view v-else class="article-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const articleState = ref("loading");
|
||||
const article = ref(null);
|
||||
const controller = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱或文章标识,页面不会展示其他谱文。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (articleState.value === "missing") {
|
||||
return {
|
||||
title: "该谱文已不存在",
|
||||
copy: "它可能已被作者删除,或当前账号已不再拥有查看权限。",
|
||||
action: "返回谱文列表",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取;本页不会替换为其他谱文。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
|
||||
const loadArticle = async () => {
|
||||
if (!hasValidContext.value) {
|
||||
articleState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
articleState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getArticleDetail(
|
||||
genealogyId.value,
|
||||
articleId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
article.value = current;
|
||||
articleState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
articleState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
articleId.value = String(query?.articleId || "");
|
||||
void loadArticle();
|
||||
});
|
||||
onShow(() => {
|
||||
if (hasValidContext.value) void loadArticle();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const backToArticles = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () =>
|
||||
articleState.value === "error" ? loadArticle() : backToArticles();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.article-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.article-detail-header,
|
||||
.article-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-detail-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.article-card,
|
||||
.article-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.article-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 30rpx;
|
||||
}
|
||||
.article-card__category,
|
||||
.article-card__title,
|
||||
.article-card__meta,
|
||||
.article-card__summary,
|
||||
.article-card__content,
|
||||
.article-card__views {
|
||||
display: block;
|
||||
}
|
||||
.article-card__category {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-card__title {
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
}
|
||||
.article-card__meta {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.article-card__summary {
|
||||
margin-top: 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-card__divider {
|
||||
height: 1rpx;
|
||||
margin: 26rpx 0;
|
||||
background: rgba(128, 89, 49, 0.22);
|
||||
}
|
||||
.article-card__content {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article-card__views {
|
||||
margin-top: 30rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
}
|
||||
.article-state-card {
|
||||
min-height: 350rpx;
|
||||
margin-top: 36rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.article-state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.article-state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.article-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,180 +0,0 @@
|
||||
<!-- 页面编号:F-08;用途:读取当前相册的照片记录。 -->
|
||||
<template>
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header"
|
||||
><PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
/></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="state === 'loading'" class="album-state-card"
|
||||
><AppLoading text="正在读取相册照片"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="album-state-card"
|
||||
><text>暂时无法读取相册照片</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadPhotos"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="album-state-card"
|
||||
><text>还没有照片</text><text>添加照片后会直接读取服务端相册记录。</text
|
||||
><AppButton block label="添加照片" @click="addPhoto"
|
||||
/></view>
|
||||
<view v-else class="photo-list">
|
||||
<view v-for="item in photos" :key="item.id" class="photo-card"
|
||||
><text>{{ item.title }}</text
|
||||
><text v-if="item.description">{{ item.description }}</text
|
||||
><text v-if="item.meta">{{ item.meta }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
const photos = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||
);
|
||||
const loadPhotos = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getAlbumPhotos(genealogyId.value, albumId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
photos.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.photoId || ""),
|
||||
title: String(item.photoTitle || "未命名照片"),
|
||||
description: String(item.photoDesc || ""),
|
||||
meta: [item.photographer, item.shootTime].filter(Boolean).join(" · "),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = photos.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.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);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
if (valid.value) loadPhotos();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadPhotos();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
.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: 16rpx;
|
||||
}
|
||||
.photo-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.photo-card text {
|
||||
display: block;
|
||||
}
|
||||
.photo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.photo-card text:not(:first-child) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家族视频" custom-back @back="returnToFamily" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">发布家族视频</text>
|
||||
<text class="video-panel__note">视频文件由上传回执自动关联,发布时不需要填写文件 ID。</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频文件</text>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="selectVideo">
|
||||
{{ uploading ? "上传中…" : receipt ? "重新选择视频" : "选择视频" }}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt">已上传:{{ receipt.fileName || "视频" }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频标题</text>
|
||||
<input v-model="form.videoTitle" maxlength="100" placeholder="例如:2026 年清明祭祖活动" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea v-model="form.videoDesc" maxlength="500" auto-height placeholder="补充视频中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在发布…' : '发布视频'" @click="submitVideo" />
|
||||
</view>
|
||||
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isVideoPickCancelled, pickAndUploadVideo } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const receipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const stateCopy = computed(() => pageState.value === "success"
|
||||
? {
|
||||
title: "视频已提交服务端",
|
||||
copy: "视频已按上传回执关联到当前家谱。视频列表接口尚未提供可消费的返回字段,因此此处不猜测播放地址或卡片内容。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "视频入口无效",
|
||||
copy: "没有取得有效的家谱标识。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
});
|
||||
onUnmounted(() => controller.abort());
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadVideo({ requestController: controller });
|
||||
} catch (error) {
|
||||
if (!isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "视频上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createVideo(genealogyId.value, {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "视频发布失败,请稍后重试";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () => hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.video-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
|
||||
.video-panel, .video-state-card { box-sizing: border-box; @include adaptive-family-content; }
|
||||
.video-panel { padding: 30rpx; }
|
||||
.video-panel__title, .video-panel__note, .video-field__label, .video-state-card text { display: block; }
|
||||
.video-panel__title, .video-state-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 34rpx, 24px); font-weight: 700; }
|
||||
.video-panel__note, .video-state-card__copy { margin-top: 12rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.6; }
|
||||
.video-field { margin-top: 24rpx; }
|
||||
.video-field__label { margin-bottom: 10rpx; color: $ink; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||
.video-field input, .video-field textarea { width: 100%; box-sizing: border-box; border: 1rpx solid rgba(143, 108, 63, .34); border-radius: 8rpx; background: rgba(255, 253, 247, .8); color: $ink; font-size: clamp(15px, 24rpx, 18px); }
|
||||
.video-field input { height: 76rpx; padding: 0 18rpx; }
|
||||
.video-field textarea { min-height: 140rpx; padding: 16rpx 18rpx; }
|
||||
.video-field--upload { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
|
||||
.video-field--upload .video-field__label { width: 100%; }
|
||||
.upload-button { margin: 0; padding: 0 26rpx; border: 1rpx solid #b78a42; border-radius: 8rpx; background: #fffaf0; color: #805723; font-size: clamp(14px, 23rpx, 17px); line-height: 64rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.required-mark, .field-error { color: $brand-red; }
|
||||
.field-error { display: block; margin-top: 12rpx; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.video-panel .app-button { margin-top: 28rpx; }
|
||||
.video-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.video-state-card .app-button { margin-top: 28rpx; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:F-03;用途:家族动态详情与一级评论。 -->
|
||||
<template>
|
||||
<view class="feed-detail-page" :class="`feed-state--${feedState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
@@ -17,9 +16,10 @@
|
||||
<view class="feed-card">
|
||||
<view class="feed-card__heading">
|
||||
<text>{{ feedTypeLabel(feed.type) }}</text>
|
||||
<text>{{ feed.time || "未标注时间" }}</text>
|
||||
<text>{{ formatMinuteTimestamp(feed.time) || "未标注时间" }}</text>
|
||||
</view>
|
||||
<text class="feed-card__content">{{ feed.content }}</text>
|
||||
<FamilyFeedMedia :files="feed.mediaFiles" />
|
||||
<view class="feed-card__meta">
|
||||
<text>发布:{{ feed.publisher }}</text>
|
||||
<text
|
||||
@@ -27,61 +27,48 @@
|
||||
{{ feed.commentCount }} 条评论</text
|
||||
>
|
||||
</view>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="isTogglingLike || !hasLikeState"
|
||||
:label="
|
||||
isTogglingLike
|
||||
? '正在提交'
|
||||
: hasLikeState
|
||||
? feed.likedByMe
|
||||
? '取消点赞'
|
||||
: '点赞'
|
||||
: '点赞状态不可用'
|
||||
"
|
||||
@click="toggleLike"
|
||||
/>
|
||||
<view class="feed-card__actions">
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="isTogglingLike || !hasLikeState"
|
||||
:label="
|
||||
isTogglingLike
|
||||
? '正在提交'
|
||||
: hasLikeState
|
||||
? feed.likedByMe
|
||||
? '取消点赞'
|
||||
: '点赞'
|
||||
: '点赞不可用'
|
||||
"
|
||||
@click="toggleLike"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="feed.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑动态"
|
||||
@click="openEditFeed"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除动态"
|
||||
@click="requestDeleteFeed"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="!hasLikeState" class="like-note"
|
||||
>点赞状态暂不可用,页面不会猜测下一次操作。</text
|
||||
>暂时无法确认是否已点赞,请稍后再试。</text
|
||||
>
|
||||
<text v-if="likeError" class="like-error">{{ likeError }}</text>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
</view>
|
||||
|
||||
<view class="comment-section">
|
||||
<text class="section-title">评论</text>
|
||||
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
|
||||
<view v-else-if="commentState === 'list'" class="comment-list">
|
||||
<view v-for="item in comments" :key="item.id" class="comment-card">
|
||||
<view class="comment-card__heading">
|
||||
<text>{{ item.author }}</text>
|
||||
<text>{{ item.time || "刚刚" }}</text>
|
||||
</view>
|
||||
<text class="comment-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="comment-state-copy">{{ commentStateCopy }}</text>
|
||||
|
||||
<view class="comment-editor">
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
auto-height
|
||||
maxlength="1000"
|
||||
placeholder="写下你的评论"
|
||||
placeholder-class="comment-editor__placeholder"
|
||||
@input="commentError = ''"
|
||||
/>
|
||||
<text v-if="commentError" class="comment-error">{{
|
||||
commentError
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmittingComment"
|
||||
:label="isSubmittingComment ? '正在提交' : '发表评论'"
|
||||
@click="submitComment"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<FeedCommentSection
|
||||
:genealogy-id="genealogyId"
|
||||
:feed-id="feedId"
|
||||
:refresh-feed-summary="refreshFeedSummary"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else class="feed-state-card">
|
||||
@@ -95,6 +82,18 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这条动态?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留动态"
|
||||
show-cancel
|
||||
@confirm="deleteFeed"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -102,29 +101,35 @@
|
||||
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 FeedCommentSection from "@/components/family/FeedCommentSection.vue";
|
||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const feedId = ref("");
|
||||
const feedState = ref("loading");
|
||||
const commentState = ref("loading");
|
||||
const feed = ref(null);
|
||||
const comments = ref([]);
|
||||
const commentDraft = ref("");
|
||||
const commentError = ref("");
|
||||
const isSubmittingComment = ref(false);
|
||||
const isTogglingLike = ref(false);
|
||||
const likeError = ref("");
|
||||
const controller = createRequestController();
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const feedReadController = createRequestController();
|
||||
const feedLikeRequestController = createRequestController();
|
||||
const feedDeletionRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const feedTypeLabel = (type) =>
|
||||
String(type || "")
|
||||
.trim()
|
||||
@@ -139,73 +144,55 @@ const hasLikeState = computed(() => typeof feed.value?.likedByMe === "boolean");
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得当前家谱和动态标识,页面不会展示其他动态。",
|
||||
title: "暂时无法打开动态",
|
||||
copy: "未找到家谱或动态信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
if (feedState.value === "missing") {
|
||||
return {
|
||||
title: "该动态已不存在",
|
||||
copy: "它可能已被发布者删除,或当前账号已不再拥有查看权限。",
|
||||
copy: "它可能已被发布者删除,或你暂时无法查看。",
|
||||
action: "返回家族动态",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "动态暂时无法读取",
|
||||
copy: "请检查网络后重新读取;本页不会替换为其他动态。",
|
||||
copy: "请检查网络后重新读取。",
|
||||
action: "重新读取",
|
||||
};
|
||||
});
|
||||
const commentStateCopy = computed(() => {
|
||||
if (commentState.value === "empty") return "还没有评论,欢迎留下第一句话。";
|
||||
return "评论暂时无法读取,稍后可重新进入本页查看。";
|
||||
});
|
||||
|
||||
const loadFeed = async () => {
|
||||
const loadFeed = async ({ preserveContent = false } = {}) => {
|
||||
if (!hasValidContext.value) {
|
||||
feedState.value = "invalid";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
feedState.value = "loading";
|
||||
if (!preserveContent) feedState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getFeedDetail(
|
||||
const current = await familyFeedApi.getFeedDetail(
|
||||
genealogyId.value,
|
||||
feedId.value,
|
||||
{ requestController: controller },
|
||||
{ requestController: feedReadController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
feed.value = current;
|
||||
feedState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
feedState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
if (!pageActive || isRequestCancelled(error)) return null;
|
||||
if (!preserveContent) {
|
||||
feedState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!hasValidContext.value || feedState.value !== "ready") return;
|
||||
commentState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
comments.value = rows;
|
||||
commentState.value = rows.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await loadFeed();
|
||||
if (feedState.value === "ready") await loadComments();
|
||||
};
|
||||
const refresh = () => loadFeed();
|
||||
const refreshFeedSummary = () => loadFeed({ preserveContent: true });
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (
|
||||
@@ -219,43 +206,66 @@ const toggleLike = async () => {
|
||||
isTogglingLike.value = true;
|
||||
likeError.value = "";
|
||||
try {
|
||||
await appApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, {
|
||||
requestController: controller,
|
||||
await familyFeedApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, {
|
||||
requestController: feedLikeRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
await loadFeed();
|
||||
const feedRefreshed = await refreshFeedSummary();
|
||||
if (feedRefreshed === false) {
|
||||
likeError.value = "点赞已提交,最新状态暂时无法读取。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
likeError.value = error?.message || "点赞操作失败,请稍后重试";
|
||||
likeError.value = getRequestErrorMessage(error, "点赞操作失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) isTogglingLike.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmittingComment.value || !hasValidContext.value) return;
|
||||
const commentContent = commentDraft.value.trim();
|
||||
if (!commentContent) {
|
||||
commentError.value = "请填写评论内容";
|
||||
return;
|
||||
}
|
||||
isSubmittingComment.value = true;
|
||||
commentError.value = "";
|
||||
const requestDeleteFeed = () => {
|
||||
if (!feed.value?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||
};
|
||||
const openEditFeed = () => {
|
||||
if (!feed.value?.canEdit || !hasValidContext.value) return;
|
||||
openPage(
|
||||
"F02",
|
||||
{ genealogyId: genealogyId.value, mode: "edit", feedId: feedId.value },
|
||||
"F03",
|
||||
);
|
||||
};
|
||||
const deleteFeed = async () => {
|
||||
if (!feed.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await appApi.createFeedComment(
|
||||
genealogyId.value,
|
||||
feedId.value,
|
||||
{ commentContent },
|
||||
{ requestController: controller },
|
||||
);
|
||||
await familyFeedApi.deleteFeed(genealogyId.value, feedId.value, {
|
||||
requestController: feedDeletionRequestController,
|
||||
});
|
||||
deletionCommitted = true;
|
||||
if (!pageActive) return;
|
||||
commentDraft.value = "";
|
||||
await refresh();
|
||||
deleteConfirmationVisible.value = false;
|
||||
feed.value = null;
|
||||
feedState.value = "missing";
|
||||
await backToFamily();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
commentError.value = error?.message || "评论提交失败,请稍后重试";
|
||||
if (!pageActive) return;
|
||||
if (deletionCommitted) {
|
||||
deleteConfirmationVisible.value = false;
|
||||
feed.value = null;
|
||||
feedState.value = "missing";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "动态删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) isSubmittingComment.value = false;
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,11 +275,17 @@ onLoad((query) => {
|
||||
void refresh();
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void refresh();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
feedReadController.abort();
|
||||
feedLikeRequestController.abort();
|
||||
feedDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const backToFamily = () =>
|
||||
@@ -303,7 +319,6 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-card,
|
||||
.comment-section,
|
||||
.feed-state-card {
|
||||
@include adaptive-family-content;
|
||||
box-sizing: border-box;
|
||||
@@ -312,20 +327,17 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
padding: 30rpx;
|
||||
}
|
||||
.feed-card__heading,
|
||||
.feed-card__meta,
|
||||
.comment-card__heading {
|
||||
.feed-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.feed-card__heading text:first-child,
|
||||
.comment-card__heading text:first-child {
|
||||
.feed-card__heading text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-card__heading text:last-child,
|
||||
.comment-card__heading text:last-child {
|
||||
.feed-card__heading text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: right;
|
||||
@@ -346,6 +358,17 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.feed-card .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.feed-card__actions .app-button {
|
||||
width: auto;
|
||||
min-width: 180rpx;
|
||||
flex: 1 1 180rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.like-note,
|
||||
.like-error {
|
||||
display: block;
|
||||
@@ -358,67 +381,12 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.like-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.comment-section {
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
.section-title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-list {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-card {
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.comment-card__content {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.comment-state-copy {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comment-editor {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.comment-editor textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 130rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.3);
|
||||
border-radius: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.comment-editor__placeholder {
|
||||
color: #ab9a86;
|
||||
}
|
||||
.comment-editor .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.comment-error {
|
||||
.delete-error {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.feed-state-card {
|
||||
min-height: 340rpx;
|
||||
@@ -1,14 +1,13 @@
|
||||
<!-- 页面编号:F-02;用途:按 Apifox 的完整 AppFamilyFeedBody 创建家族动态。 -->
|
||||
<template>
|
||||
<view class="publish-page" :class="`publish-state--${publishState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="publish-page__header"
|
||||
><PageHeader title="发布动态" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑动态' : '发布动态'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="publish-panel">
|
||||
<view v-if="publishState === 'form'" class="publish-form">
|
||||
<text>记录此刻</text>
|
||||
<text>填写需要的内容;留空的字段由服务端按接口默认行为处理。</text>
|
||||
<text>{{ isEdit ? '编辑此刻' : '记录此刻' }}</text>
|
||||
<text>{{ isEdit ? '原有图片和相关设置会保留。' : '填写要发布的内容,其余项目可按需补充。' }}</text>
|
||||
|
||||
<view class="publish-field publish-field--content">
|
||||
<text class="publish-field__label"
|
||||
@@ -33,7 +32,7 @@
|
||||
<view>
|
||||
<text class="publish-field__label">动态配图</text>
|
||||
<text class="publish-field__hint"
|
||||
>图片选定后会先取得真实上传回执,并在提交动态时关联。</text
|
||||
>图片上传成功后,会随动态一起发布。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -53,24 +52,17 @@
|
||||
uploadError
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="publish-field">
|
||||
<text class="publish-field__label">排序值</text>
|
||||
<input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="留空时服务端默认为 0"
|
||||
placeholder-class="publish-placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在提交' : '提交动态'"
|
||||
:label="isSubmitting ? '正在提交' : isEdit ? '保存动态' : '提交动态'"
|
||||
:disabled="isSubmitting || isUploading"
|
||||
@click="submit"
|
||||
@click="saveFeed"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="publishState === 'loading'" class="publish-result"
|
||||
><AppLoading text="正在读取动态"
|
||||
/></view>
|
||||
<view v-else class="publish-result">
|
||||
<text>{{ resultCopy.title }}</text>
|
||||
<text>{{ resultCopy.copy }}</text>
|
||||
@@ -84,7 +76,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃动态草稿?"
|
||||
message="当前内容尚未提交服务器,确认返回后不会保留。"
|
||||
message="动态还没有发布,确认返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -96,57 +88,70 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const form = reactive({ feedContent: "", feedType: "text", sortOrder: "" });
|
||||
const form = reactive({ feedContent: "", feedType: "text" });
|
||||
const mediaReceipts = ref([]);
|
||||
const publishState = ref("form");
|
||||
const publishState = ref("loading");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const requestController = createRequestController();
|
||||
const editingFeed = ref(null);
|
||||
const editFeedId = ref("");
|
||||
const formBaseline = ref("");
|
||||
const submittedEdit = ref(false);
|
||||
const feedDetailController = createRequestController();
|
||||
const feedMediaUploadController = createRequestController();
|
||||
const feedSaveController = createRequestController();
|
||||
const feedCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const isEdit = computed(() => Boolean(editingFeed.value));
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const isDirty = computed(() =>
|
||||
Boolean(
|
||||
form.feedContent.trim() ||
|
||||
form.sortOrder.trim() ||
|
||||
mediaReceipts.value.length,
|
||||
),
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Boolean(form.feedContent.trim() || mediaReceipts.value.length),
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
success: {
|
||||
title: "动态已提交服务端",
|
||||
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
|
||||
action: "返回家族动态",
|
||||
title: submittedEdit.value ? "动态已更新" : "动态发布成功",
|
||||
copy: "已保存,返回后会显示最新内容。",
|
||||
action: submittedEdit.value ? "返回动态详情" : "返回家族动态",
|
||||
},
|
||||
error: {
|
||||
title: "动态未提交",
|
||||
@@ -154,10 +159,15 @@ const resultCopy = computed(
|
||||
action: "返回填写",
|
||||
},
|
||||
invalid: {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
|
||||
title: "暂时无法打开动态",
|
||||
copy: "未找到家谱信息,请返回家谱首页后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
unavailable: {
|
||||
title: "暂时无法编辑动态",
|
||||
copy: "这条动态已经变化,暂未保存。",
|
||||
action: "返回动态详情",
|
||||
},
|
||||
})[publishState.value],
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -167,9 +177,62 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, { feedContent: "", feedType: "text" });
|
||||
mediaReceipts.value = [];
|
||||
editingFeed.value = null;
|
||||
formBaseline.value = "";
|
||||
submitError.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadEditFeed = async (feedId) => {
|
||||
try {
|
||||
const detail = await familyFeedApi.getFeedDetail(genealogyId.value, feedId, {
|
||||
requestController: feedDetailController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
detail.type.toLowerCase() !== "text" ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("这条动态的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
resetForm();
|
||||
Object.assign(form, { feedContent: detail.content, feedType: detail.type });
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingFeed.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
publishState.value = "form";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) publishState.value = "unavailable";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) publishState.value = "invalid";
|
||||
if (!hasValidContext.value) {
|
||||
publishState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (query?.mode === "create") {
|
||||
publishState.value = "form";
|
||||
return;
|
||||
}
|
||||
const feedId = String(query?.feedId || "");
|
||||
if (query?.mode === "edit" && /^[1-9]\d*$/.test(feedId)) {
|
||||
editFeedId.value = feedId;
|
||||
loadEditFeed(feedId);
|
||||
return;
|
||||
}
|
||||
publishState.value = "invalid";
|
||||
});
|
||||
|
||||
const uploadImage = async () => {
|
||||
@@ -177,45 +240,74 @@ const uploadImage = async () => {
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: feedMediaUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
if (pageActive) isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const saveFeed = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||
if (!form.feedContent.trim()) {
|
||||
submitError.value = "请填写动态内容";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingFeed.value
|
||||
? {
|
||||
sortOrder: editingFeed.value.sortOrder,
|
||||
status: editingFeed.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingFeed.value ? null : feedCreateGuard.begin(payload);
|
||||
if (!editingFeed.value && createAttempt === null) {
|
||||
publishState.value = "error";
|
||||
submitError.value =
|
||||
"上次发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createFeed(
|
||||
genealogyId.value,
|
||||
{
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
Object.assign(form, { feedContent: "", feedType: "text", sortOrder: "" });
|
||||
mediaReceipts.value = [];
|
||||
submittedEdit.value = Boolean(editingFeed.value);
|
||||
if (editingFeed.value) {
|
||||
await familyFeedApi.updateFeed(genealogyId.value, editingFeed.value.id, payload, {
|
||||
requestController: feedSaveController,
|
||||
});
|
||||
} else {
|
||||
await familyFeedApi.createFeed(genealogyId.value, payload, {
|
||||
requestController: feedSaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
publishState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!editingFeed.value && feedCreateGuard.recordFailure(createAttempt, error)) {
|
||||
publishState.value = "error";
|
||||
submitError.value =
|
||||
"发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
publishState.value = "error";
|
||||
submitError.value = error?.message || "动态提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "动态提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
@@ -233,16 +325,32 @@ const returnToFamily = async () => {
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (publishState.value === "success") return returnToFamily();
|
||||
if (publishState.value === "success") {
|
||||
return submittedEdit.value
|
||||
? returnTo("F03", {
|
||||
genealogyId: genealogyId.value,
|
||||
feedId: editFeedId.value,
|
||||
})
|
||||
: returnToFamily();
|
||||
}
|
||||
if (publishState.value === "error") {
|
||||
publishState.value = "form";
|
||||
return;
|
||||
}
|
||||
if (publishState.value === "unavailable") {
|
||||
return returnTo("F03", {
|
||||
genealogyId: genealogyId.value,
|
||||
feedId: editFeedId.value,
|
||||
});
|
||||
}
|
||||
return goBack();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
feedDetailController.abort();
|
||||
feedMediaUploadController.abort();
|
||||
feedSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -13,12 +13,13 @@
|
||||
><text>家族圈</text><text>家宴、通知与共同记忆</text></view
|
||||
>
|
||||
<view v-if="hasValidContext" class="feed-shortcuts"
|
||||
><view
|
||||
><button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.key"
|
||||
class="feed-shortcut"
|
||||
:aria-label="`打开${item.label}`"
|
||||
@click="openSection(item.key)"
|
||||
><text>{{ item.label }}</text></view
|
||||
><text>{{ item.label }}</text></button
|
||||
></view
|
||||
>
|
||||
<AppLoading
|
||||
@@ -26,19 +27,26 @@
|
||||
text="正在读取家族动态"
|
||||
description="请稍候,正在整理家族近况。"
|
||||
/>
|
||||
<view v-else-if="feedState === 'list'" class="feed-list"
|
||||
><view
|
||||
<view v-else-if="feedState === 'list'" class="feed-list">
|
||||
<view
|
||||
v-for="item in feeds"
|
||||
:key="item.id"
|
||||
class="feed-card"
|
||||
@click="openFeed(item)"
|
||||
><text class="feed-card__publisher">{{ item.publisher }}</text
|
||||
><text class="feed-card__content">{{ item.content }}</text
|
||||
><text class="feed-card__meta"
|
||||
>{{ feedTypeLabel(item.type) }} · {{ item.time }}</text
|
||||
></view
|
||||
></view
|
||||
>
|
||||
>
|
||||
<text class="feed-card__publisher">{{ item.publisher }}</text>
|
||||
<text class="feed-card__content">{{ feedPreview(item.content) }}</text>
|
||||
<FamilyFeedMedia :files="item.mediaFiles" />
|
||||
<text class="feed-card__meta">{{ feedTypeLabel(item.type) }} · {{ formatMinuteTimestamp(item.time) }}</text>
|
||||
<text v-if="item.recommendationReason" class="feed-card__reason">{{ item.recommendationReason }}</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="hasMoreFeeds || loadMoreState === 'loading' || loadMoreState === 'error'"
|
||||
class="feed-more"
|
||||
:disabled="loadMoreState === 'loading'"
|
||||
@click="loadMoreFeeds"
|
||||
>{{ loadMoreLabel }}</button>
|
||||
</view>
|
||||
<view v-else class="feed-state-card"
|
||||
><view class="feed-state-card__copy"
|
||||
><text>{{ stateCopy.title }}</text
|
||||
@@ -55,26 +63,51 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, isRequestCancelled } from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const feedState = ref("loading");
|
||||
const feeds = ref([]);
|
||||
const FEED_PAGE_SIZE = 20;
|
||||
const totalFeedCount = ref(0);
|
||||
const currentFeedPage = ref(1);
|
||||
const loadMoreState = ref("idle");
|
||||
const hasMoreFeeds = computed(
|
||||
() => feedState.value === "list" && feeds.value.length < totalFeedCount.value,
|
||||
);
|
||||
const loadMoreLabel = computed(() => {
|
||||
if (loadMoreState.value === "loading") return "正在加载";
|
||||
if (loadMoreState.value === "error") return "加载失败,重新加载";
|
||||
return "继续加载";
|
||||
});
|
||||
let active = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const feedListRequestController = createRequestController();
|
||||
const recommendationRequestController = createRequestController();
|
||||
const feedTypeLabel = (type) =>
|
||||
String(type || "")
|
||||
.trim()
|
||||
.toLowerCase() === "text"
|
||||
? "文字动态"
|
||||
: "家族动态";
|
||||
const feedPreview = (content) => {
|
||||
const text = String(content || "").trim();
|
||||
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}…` : text;
|
||||
};
|
||||
const shortcuts = [
|
||||
{ key: "articles", label: "谱文" },
|
||||
{ key: "albums", label: "相册" },
|
||||
@@ -109,16 +142,67 @@ const stateCopy = computed(() =>
|
||||
const loadFeeds = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
feedState.value = "loading";
|
||||
loadMoreState.value = "idle";
|
||||
currentFeedPage.value = 1;
|
||||
feedListRequestController.abort();
|
||||
recommendationRequestController.abort();
|
||||
try {
|
||||
const rows = await appApi.getFeeds(genealogyId.value);
|
||||
const [pageResult, recommendationResult] = await Promise.allSettled([
|
||||
familyFeedApi.getFeedPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: 1, pageSize: FEED_PAGE_SIZE },
|
||||
{ requestController: feedListRequestController },
|
||||
),
|
||||
familyFeedApi.getFeedRecommendations(genealogyId.value, {
|
||||
requestController: recommendationRequestController,
|
||||
}),
|
||||
]);
|
||||
if (pageResult.status === "rejected") throw pageResult.reason;
|
||||
if (!active) return;
|
||||
feeds.value = rows;
|
||||
const recommendations =
|
||||
recommendationResult.status === "fulfilled"
|
||||
? recommendationResult.value
|
||||
: [];
|
||||
const reasons = new Map(
|
||||
recommendations.map((item) => [String(item.id), item.recommendationReason]),
|
||||
);
|
||||
feeds.value = pageResult.value.rows.map((item) => ({
|
||||
...item,
|
||||
recommendationReason: reasons.get(String(item.id)) || "",
|
||||
}));
|
||||
totalFeedCount.value = pageResult.value.total;
|
||||
loadMoreState.value =
|
||||
feeds.value.length < totalFeedCount.value ? "idle" : "done";
|
||||
feedState.value = feeds.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
feedState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadMoreFeeds = async () => {
|
||||
if (!hasMoreFeeds.value || loadMoreState.value === "loading") return;
|
||||
loadMoreState.value = "loading";
|
||||
try {
|
||||
const nextPage = currentFeedPage.value + 1;
|
||||
const page = await familyFeedApi.getFeedPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: nextPage, pageSize: FEED_PAGE_SIZE },
|
||||
{ requestController: feedListRequestController },
|
||||
);
|
||||
if (!active) return;
|
||||
const knownIds = new Set(feeds.value.map((item) => String(item.id)));
|
||||
feeds.value = feeds.value.concat(
|
||||
page.rows.filter((item) => !knownIds.has(String(item.id))),
|
||||
);
|
||||
currentFeedPage.value = nextPage;
|
||||
totalFeedCount.value = page.total;
|
||||
loadMoreState.value =
|
||||
feeds.value.length < totalFeedCount.value ? "idle" : "done";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
loadMoreState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
const supplied = Object.prototype.hasOwnProperty.call(
|
||||
query || {},
|
||||
@@ -132,11 +216,20 @@ onLoad((query) => {
|
||||
else feedState.value = "error";
|
||||
});
|
||||
onShow(() => {
|
||||
if (skipInitialShowRefresh) {
|
||||
skipInitialShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (hasValidContext.value) void loadFeeds();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
feedListRequestController.abort();
|
||||
recommendationRequestController.abort();
|
||||
});
|
||||
const toPublish = () =>
|
||||
hasValidContext.value
|
||||
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
|
||||
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F01")
|
||||
: goRoot("G01");
|
||||
const openFeed = (item) =>
|
||||
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
|
||||
@@ -190,10 +283,17 @@ const handlePrimaryAction = () =>
|
||||
.feed-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8rpx;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
.feed-shortcut {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.45);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 252, 244, 0.58);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -237,6 +337,37 @@ const handlePrimaryAction = () =>
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.feed-shortcut::after {
|
||||
border: 0;
|
||||
}
|
||||
.feed-shortcut:active {
|
||||
background: rgba(181, 137, 63, 0.1);
|
||||
}
|
||||
.feed-card__reason {
|
||||
margin-top: 8rpx;
|
||||
color: #9a6555;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.feed-more {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
display: block;
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin: 22rpx auto 0;
|
||||
padding: 0 24rpx;
|
||||
border: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
line-height: 76rpx;
|
||||
}
|
||||
.feed-more::after {
|
||||
border: 0;
|
||||
}
|
||||
.feed-more[disabled] {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.feed-state-card {
|
||||
min-height: 230rpx;
|
||||
display: flex;
|
||||
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="家族视频"
|
||||
:action="pageState === 'list' ? '发布' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="openPublishForm"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">{{
|
||||
isEdit ? "编辑家族视频" : "发布家族视频"
|
||||
}}</text>
|
||||
<text class="video-panel__note">{{
|
||||
isEdit
|
||||
? "原视频、封面和相关设置会保留。"
|
||||
: "视频上传成功后会自动关联,无需额外填写。"
|
||||
}}</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"
|
||||
><text class="required-mark">*</text>视频文件</text
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="isEdit || uploading || coverUploading || submitting"
|
||||
@click="selectVideo"
|
||||
>
|
||||
{{
|
||||
isEdit
|
||||
? "当前视频已关联"
|
||||
: uploading
|
||||
? "上传中…"
|
||||
: receipt
|
||||
? "重新选择视频"
|
||||
: "选择视频"
|
||||
}}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt"
|
||||
>已上传:{{ receipt.fileName || "视频" }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label">视频封面</text>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || coverUploading || submitting"
|
||||
@click="selectCover"
|
||||
>
|
||||
{{
|
||||
coverUploading
|
||||
? "上传中…"
|
||||
: coverReceipt
|
||||
? "重新选择封面"
|
||||
: "选择图片"
|
||||
}}
|
||||
</button>
|
||||
<text v-if="coverReceipt" class="upload-receipt"
|
||||
>已上传:{{ coverReceipt.fileName || "视频封面" }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="coverUploadError" class="field-error">{{
|
||||
coverUploadError
|
||||
}}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"
|
||||
><text class="required-mark">*</text>视频标题</text
|
||||
>
|
||||
<input
|
||||
v-model="form.videoTitle"
|
||||
maxlength="100"
|
||||
placeholder="例如:2026 年清明祭祖活动"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea
|
||||
v-model="form.videoDesc"
|
||||
maxlength="500"
|
||||
auto-height
|
||||
placeholder="补充视频中的人物、场景或故事"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="uploading || coverUploading || submitting"
|
||||
:label="submitting ? '正在提交…' : isEdit ? '保存视频' : '发布视频'"
|
||||
@click="submitVideo"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'form-loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取视频详情" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'list'" class="video-list-panel">
|
||||
<view v-if="videoListState === 'loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取家族视频" />
|
||||
</view>
|
||||
<view v-else-if="videoListState === 'error'" class="video-state-card">
|
||||
<text class="video-state-card__title">暂时无法读取视频</text>
|
||||
<text class="video-state-card__copy">请检查网络后重新加载。</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadVideos"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="!videos.length" class="video-state-card">
|
||||
<text class="video-state-card__title">还没有家族视频</text>
|
||||
<text class="video-state-card__copy"
|
||||
>可以先发布一段值得留存的影像。</text
|
||||
>
|
||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||
</view>
|
||||
<view v-else class="video-card-list">
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<video
|
||||
class="video-card__player"
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
/>
|
||||
<text class="video-card__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="video-card__copy">{{
|
||||
video.description
|
||||
}}</text>
|
||||
<text class="video-card__meta">{{
|
||||
video.publishTime || "刚刚发布"
|
||||
}}</text>
|
||||
<view
|
||||
v-if="video.canEdit || video.canDelete"
|
||||
class="video-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
v-if="video.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑视频"
|
||||
@click="openEditVideo(video)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="deletingVideoId === video.id"
|
||||
:label="deletingVideoId === video.id ? '正在删除' : '删除视频'"
|
||||
@click="requestDeleteVideo(video)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="videoActionError" class="field-error">{{
|
||||
videoActionError
|
||||
}}</text>
|
||||
</view>
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这段家族视频?"
|
||||
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
|
||||
:confirm-text="deletingVideoId ? '正在删除' : '确认删除'"
|
||||
cancel-text="保留视频"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDeleteVideo"
|
||||
@cancel="deleteConfirmVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
isVideoPickCancelled,
|
||||
pickAndUploadImage,
|
||||
pickAndUploadVideo,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("list");
|
||||
const videoListState = ref("loading");
|
||||
const videos = ref([]);
|
||||
const receipt = ref(null);
|
||||
const coverReceipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const coverUploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverUploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const editingVideo = ref(null);
|
||||
const videoListRequestController = createRequestController();
|
||||
const videoDetailRequestController = createRequestController();
|
||||
const videoUploadRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const videoSaveRequestController = createRequestController();
|
||||
const videoDeletionRequestController = createRequestController();
|
||||
const videoCreateGuard = createNonIdempotentWriteGuard();
|
||||
const deleteTarget = ref(null);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
const deletingVideoId = ref("");
|
||||
const videoActionError = ref("");
|
||||
let pageActive = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingVideo.value));
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
title: "视频已保存",
|
||||
copy: "视频已关联到当前家谱,播放内容准备好后会在这里显示。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "暂时无法发布视频",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
else loadVideos();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
videoListRequestController.abort();
|
||||
videoDetailRequestController.abort();
|
||||
videoUploadRequestController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
videoSaveRequestController.abort();
|
||||
videoDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const loadVideos = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
videoListState.value = "loading";
|
||||
try {
|
||||
const videoRows = await familyMediaApi.getVideos(genealogyId.value, {
|
||||
requestController: videoListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
videos.value = videoRows;
|
||||
videoListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoListState.value = "error";
|
||||
}
|
||||
};
|
||||
const openPublishForm = () => {
|
||||
if (!hasValidContext.value) return;
|
||||
receipt.value = null;
|
||||
coverReceipt.value = null;
|
||||
editingVideo.value = null;
|
||||
form.videoTitle = "";
|
||||
form.videoDesc = "";
|
||||
uploadError.value = "";
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
};
|
||||
const openEditVideo = async (video) => {
|
||||
if (
|
||||
!video?.canEdit ||
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return;
|
||||
pageState.value = "form-loading";
|
||||
videoActionError.value = "";
|
||||
try {
|
||||
const detail = await familyMediaApi.getVideoDetail(genealogyId.value, video.id, {
|
||||
requestController: videoDetailRequestController,
|
||||
});
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.videoFile?.ossId ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.durationSeconds) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("视频信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
receipt.value = {
|
||||
ossId: detail.videoFile.ossId,
|
||||
fileName: detail.videoFile.fileName,
|
||||
};
|
||||
coverReceipt.value = detail.coverFile
|
||||
? { ossId: detail.coverFile.ossId, fileName: detail.coverFile.fileName }
|
||||
: null;
|
||||
form.videoTitle = detail.title;
|
||||
form.videoDesc = detail.description;
|
||||
editingVideo.value = {
|
||||
id: detail.id,
|
||||
durationSeconds: detail.durationSeconds,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
uploadError.value = "";
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) {
|
||||
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
|
||||
pageState.value = "list";
|
||||
}
|
||||
}
|
||||
};
|
||||
const requestDeleteVideo = (video) => {
|
||||
if (!video?.canDelete || deletingVideoId.value) return;
|
||||
videoActionError.value = "";
|
||||
deleteTarget.value = video;
|
||||
deleteConfirmVisible.value = true;
|
||||
};
|
||||
const confirmDeleteVideo = async () => {
|
||||
const target = deleteTarget.value;
|
||||
if (!target?.canDelete || deletingVideoId.value) return;
|
||||
deletingVideoId.value = target.id;
|
||||
videoActionError.value = "";
|
||||
try {
|
||||
await familyMediaApi.deleteVideo(genealogyId.value, target.id, {
|
||||
requestController: videoDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadVideos();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoActionError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"视频删除失败,请稍后重试",
|
||||
);
|
||||
deleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deletingVideoId.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (
|
||||
isEdit.value ||
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const videoReceipt = await pickAndUploadVideo({
|
||||
requestController: videoUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
receipt.value = videoReceipt;
|
||||
} catch (error) {
|
||||
if (pageActive && !isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "视频上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectCover = async () => {
|
||||
if (uploading.value || coverUploading.value || submitting.value) return;
|
||||
coverUploading.value = true;
|
||||
coverUploadError.value = "";
|
||||
try {
|
||||
const uploadedCover = await pickAndUploadImage({
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverReceipt.value = uploadedCover;
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
coverUploadError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"视频封面上传失败,请稍后重试",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) coverUploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value ||
|
||||
!hasValidContext.value
|
||||
)
|
||||
return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
|
||||
...(editingVideo.value
|
||||
? {
|
||||
durationSeconds: editingVideo.value.durationSeconds,
|
||||
sortOrder: editingVideo.value.sortOrder,
|
||||
status: editingVideo.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingVideo.value ? null : videoCreateGuard.begin(payload);
|
||||
if (!editingVideo.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
if (editingVideo.value) {
|
||||
await familyMediaApi.updateVideo(
|
||||
genealogyId.value,
|
||||
editingVideo.value.id,
|
||||
payload,
|
||||
{
|
||||
requestController: videoSaveRequestController,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await familyMediaApi.createVideo(genealogyId.value, payload, {
|
||||
requestController: videoSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
editingVideo.value = null;
|
||||
pageState.value = "list";
|
||||
await loadVideos();
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!editingVideo.value && videoCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = getRequestErrorMessage(error, "视频发布失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.video-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.video-panel,
|
||||
.video-state-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.video-list-panel {
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.video-panel {
|
||||
padding: 30rpx;
|
||||
}
|
||||
.video-panel__title,
|
||||
.video-panel__note,
|
||||
.video-field__label,
|
||||
.video-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.video-panel__title,
|
||||
.video-state-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-panel__note,
|
||||
.video-state-card__copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.video-field {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.video-field__label {
|
||||
margin-bottom: 10rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-field input,
|
||||
.video-field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.video-field input {
|
||||
min-height: 76rpx;
|
||||
padding: 0 18rpx;
|
||||
}
|
||||
.video-field textarea {
|
||||
min-height: 140rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
}
|
||||
.video-field--upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.video-field--upload .video-field__label {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-button {
|
||||
margin: 0;
|
||||
padding: 0 26rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 8rpx;
|
||||
background: #fffaf0;
|
||||
color: #805723;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 64rpx;
|
||||
}
|
||||
.upload-receipt {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.required-mark,
|
||||
.field-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.video-panel .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.video-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-card-list {
|
||||
display: grid;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.video-card {
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
}
|
||||
.video-card__player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #161616;
|
||||
}
|
||||
.video-card__title,
|
||||
.video-card__copy,
|
||||
.video-card__meta {
|
||||
display: block;
|
||||
}
|
||||
.video-card__title {
|
||||
margin-top: 16rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-card__copy {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.video-card__meta {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.video-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user