feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
@@ -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>
+266
View File
@@ -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;
+457
View File
@@ -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;
-232
View File
@@ -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>
-180
View File
@@ -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>
-150
View File
@@ -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;
+660
View File
@@ -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>