Files
jiapuapp/pages/family/feed-editor.vue
T
2026-09-13 17:45:52 +08:00

634 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="publish-page" :class="`publish-state--${publishState}`">
<ModulePageBackground module="family" />
<view class="publish-page__header"
><PageHeader :title="isEdit ? '编辑动态' : '发布动态'" custom-back @back="requestBack"
/></view>
<view class="publish-panel">
<view v-if="publishState === 'form'" class="publish-form">
<text>{{ isEdit ? '编辑此刻' : '记录此刻' }}</text>
<text>{{ isEdit ? '原有图片和相关设置会保留。' : '填写要发布的内容,其余项目可按需补充。' }}</text>
<view class="publish-field publish-field--content">
<text class="publish-field__label"
><text class="required-mark">*</text>动态内容</text
>
<textarea
v-model="form.feedContent"
auto-height
placeholder="写下想对家人说的话"
placeholder-class="publish-placeholder"
@input="submitError = ''"
/>
</view>
<view class="publish-field">
<text class="publish-field__label">动态类型</text>
<view class="publish-field__value publish-field__value--readonly">
<text>文字动态</text>
<text>当前仅支持此类型</text>
</view>
</view>
<view class="publish-field publish-field--media">
<view>
<text class="publish-field__label">动态配图</text>
<text class="publish-field__hint"
>{{ isEdit ? "可新增、预览或移除图片,保存后生效。" : "图片上传成功后,会随动态一起发布。" }}</text
>
</view>
<button
class="upload-button"
:disabled="isUploading || isSubmitting"
@click="uploadImage"
>
{{ isUploading ? "上传中…" : "添加图片" }}
</button>
<template v-if="isEdit">
<view v-if="mediaReceipts.length" class="media-preview-grid">
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="media-preview-card"
>
<button
class="media-preview-card__preview"
:disabled="isUploading || isSubmitting || !getMediaPreviewUrl(receipt)"
:aria-label="`预览第${index + 1}张动态配图`"
@click="previewMedia(receipt)"
>
<image
v-if="getMediaPreviewUrl(receipt)"
class="media-preview-card__image"
:src="getMediaPreviewUrl(receipt)"
mode="aspectFill"
/>
<view v-else class="media-preview-card__fallback">
<text>图片暂不可预览</text>
</view>
</button>
<view class="media-preview-card__meta">
<text>{{ receipt.fileName || `动态配图${index + 1}` }}</text>
<button
class="media-preview-card__remove"
:disabled="isUploading || isSubmitting"
:aria-label="`移除第${index + 1}张动态配图`"
@click="removeMedia(index)"
>移除</button>
</view>
</view>
</view>
</template>
<template v-else>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="upload-receipt"
>已上传{{ receipt.fileName || "图片" }}</text
>
</template>
<text v-if="uploadError" class="publish-error">{{
uploadError
}}</text>
</view>
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
<AppButton
block
:label="isSubmitting ? '正在提交' : isEdit ? '保存动态' : '提交动态'"
:disabled="isSubmitting || isUploading"
@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>
<AppButton
block
:label="resultCopy.action"
@click="handleResultAction"
/>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃动态草稿?"
message="动态还没有发布,确认返回后将不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
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 {
createRequestController,
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/media-upload.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const form = reactive({ feedContent: "", feedType: "text" });
const mediaReceipts = ref([]);
const publishState = ref("loading");
const isSubmitting = ref(false);
const isUploading = ref(false);
const submitError = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
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(() =>
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: submittedEdit.value ? "动态已更新" : "动态发布成功",
copy: "已保存,返回后会显示最新内容。",
action: submittedEdit.value ? "返回动态详情" : "返回家族动态",
},
error: {
title: "动态未提交",
copy: submitError.value || "当前内容仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
title: "暂时无法打开动态",
copy: "未找到家谱信息,请返回家谱首页后重新进入。",
action: "返回上一页",
},
unavailable: {
title: "暂时无法编辑动态",
copy: "这条动态已经变化,暂未保存。",
action: "返回动态详情",
},
})[publishState.value],
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
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,
accessUrl: file.accessUrl,
}));
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";
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 () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: feedMediaUploadController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const getMediaPreviewUrl = (receipt) =>
String(receipt?.thumbnailUrl || receipt?.accessUrl || receipt?.url || "").trim();
const previewMedia = (receipt) => {
const current = getMediaPreviewUrl(receipt);
if (!current) return;
const urls = mediaReceipts.value.map(getMediaPreviewUrl).filter(Boolean);
uni.previewImage({ current, urls });
};
const removeMedia = (index) => {
if (isUploading.value || isSubmitting.value || !Number.isInteger(index)) return;
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
uploadError.value = "";
};
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 {
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 = getRequestErrorMessage(error, "动态提交失败,请稍后重试。");
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F12", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
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));
onUnload(() => {
pageActive = false;
feedDetailController.abort();
feedMediaUploadController.abort();
feedSaveController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.publish-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.publish-page__header,
.publish-panel {
z-index: 1;
}
.publish-panel {
@include adaptive-family-content;
width: calc(100% - 32rpx);
margin: 18rpx auto calc(48rpx + env(safe-area-inset-bottom));
padding: 9%;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.publish-form > text,
.publish-result > text {
display: block;
}
.publish-form > text:first-child,
.publish-result > text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 36rpx, 24px);
font-weight: 700;
}
.publish-form > text:nth-child(2),
.publish-result > text:nth-child(2) {
margin-top: 12rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.6;
}
.publish-field {
@include adaptive-family-field;
margin-top: 20rpx;
padding: 18rpx 22rpx;
}
.publish-field__label {
display: block;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 700;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
line-height: 1;
transform: translateY(-3rpx);
}
.publish-field input,
.publish-field textarea {
display: block;
box-sizing: border-box;
width: 100%;
margin-top: 12rpx;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.publish-field input {
min-height: 80rpx;
}
.publish-field__value--readonly {
display: flex;
min-height: 80rpx;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
padding: 0 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 10rpx;
background: rgba(255, 252, 245, 0.48);
}
.publish-field__value--readonly text:first-child {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.publish-field__value--readonly text:last-child {
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
}
.publish-field--content textarea {
min-height: 200rpx;
line-height: 1.7;
}
.publish-field--media {
display: grid;
gap: 12rpx;
}
.publish-field__hint {
display: block;
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.45;
}
.upload-button {
justify-self: start;
min-height: 80rpx;
margin: 0;
padding: 0 20rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.media-preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.media-preview-card {
min-width: 0;
overflow: hidden;
border: 1rpx solid rgba(128, 89, 49, 0.24);
border-radius: 10rpx;
background: rgba(255, 253, 248, 0.62);
}
.media-preview-card__preview {
display: block;
width: 100%;
height: 176rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: rgba(128, 89, 49, 0.1);
line-height: 1;
}
.media-preview-card__preview::after,
.media-preview-card__remove::after {
border: 0;
}
.media-preview-card__preview:focus-visible,
.media-preview-card__remove:focus-visible {
outline: 2rpx solid $brand-red;
outline-offset: -2rpx;
}
.media-preview-card__preview:active:not([disabled]) {
background: rgba(128, 89, 49, 0.16);
}
.media-preview-card__preview[disabled],
.media-preview-card__remove[disabled] {
opacity: 0.55;
}
.media-preview-card__image,
.media-preview-card__fallback {
display: block;
width: 100%;
height: 100%;
}
.media-preview-card__fallback {
display: flex;
align-items: center;
justify-content: center;
color: $ink-muted;
font-size: clamp(12px, 19rpx, 15px);
}
.media-preview-card__meta {
display: flex;
min-height: 72rpx;
align-items: center;
gap: 8rpx;
padding: 6rpx 6rpx 6rpx 14rpx;
}
.media-preview-card__meta > text {
min-width: 0;
flex: 1;
overflow: hidden;
color: $ink-muted;
font-size: clamp(12px, 19rpx, 15px);
text-overflow: ellipsis;
white-space: nowrap;
}
.media-preview-card__remove {
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin: 0;
padding: 0 10rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1;
white-space: nowrap;
}
.media-preview-card__remove:active:not([disabled]) {
background: rgba(159, 23, 15, 0.08);
}
.publish-placeholder {
color: #8e806e;
}
.publish-error {
margin-top: 12rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.publish-form > .app-button {
margin-top: 24rpx;
}
.publish-result {
margin-top: 20%;
text-align: center;
}
.publish-result > .app-button {
margin: 30rpx auto 0;
}
</style>