Files
jiapuapp/pages/family/f02-publish-feed.vue
T
2026-07-27 06:50:23 +08:00

211 lines
10 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.
<!-- 页面编号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" /></view>
<view class="publish-panel">
<view v-if="publishState === 'form'" class="publish-form">
<text>记录此刻</text>
<text>填写需要的内容留空的字段由服务端按接口默认行为处理</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">图片选定后会先取得真实上传回执并在提交动态时关联</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadImage">{{ isUploading ? '上传中…' : '添加图片' }}</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">已上传{{ receipt.fileName || '图片' }}</text>
<text v-if="uploadError" class="publish-error">{{ 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 ? '正在提交' : '提交动态'" :disabled="isSubmitting || isUploading" @click="submit" />
</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, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } 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";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const form = reactive({ feedContent: "", feedType: "text", sortOrder: "" });
const mediaReceipts = ref([]);
const publishState = ref("form");
const isSubmitting = ref(false);
const isUploading = ref(false);
const submitError = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const requestController = createRequestController();
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const isDirty = computed(() => Boolean(
form.feedContent.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => ({
success: {
title: "动态已提交服务端",
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
action: "返回家族动态",
},
error: {
title: "动态未提交",
copy: "当前内容仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
title: "动态入口无效",
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
action: "返回上一页",
},
}[publishState.value]));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value) publishState.value = "invalid";
});
const uploadImage = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
}
} finally {
isUploading.value = false;
}
};
const submit = async () => {
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.feedContent.trim()) {
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 = [];
publishState.value = "success";
} catch (error) {
if (isRequestCancelled(error)) return;
publishState.value = "error";
submitError.value = error?.message || "动态提交失败,请稍后重试。";
} finally {
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("F01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "success") return returnToFamily();
if (publishState.value === "error") {
publishState.value = "form";
return;
}
return goBack();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.publish-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.publish-page__header, .publish-panel { @include adaptive.adaptive-family-content; z-index: 1; }
.publish-panel { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 9%; box-sizing: border-box; }
.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: 36rpx; font-weight: 700; }
.publish-form > text:nth-child(2), .publish-result > text:nth-child(2) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.publish-field { @include adaptive.adaptive-family-field; margin-top: 20rpx; padding: 18rpx 22rpx; }
.publish-field__label { display: block; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; 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: 24rpx; }
.publish-field input { min-height: 64rpx; }
.publish-field__value--readonly { display:flex; min-height:76rpx; align-items:center; justify-content:space-between; margin-top:12rpx; padding:0 20rpx; border:1rpx solid rgba(128,89,49,.22); border-radius:10rpx; background:rgba(255,252,245,.48); }
.publish-field__value--readonly text:first-child { color:$ink; font-size:24rpx; }
.publish-field__value--readonly text:last-child { color:$ink-muted; font-size:20rpx; }
.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: 20rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.publish-placeholder { color: #8e806e; }
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; 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>