Files
jiapuapp/pages/family/f06-article-editor.vue
T
2026-07-27 06:50:23 +08:00

222 lines
12 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-06用途 Apifox 的完整 AppArticleBody 创建谱文 -->
<template>
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
<ModulePageBackground module="family" />
<view class="article-editor-page__header"><PageHeader title="新建谱文" custom-back @back="requestBack" /></view>
<view class="article-editor-content">
<view v-if="editorState === 'form'" class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">服务端创建</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro">除标题和正文外其余字段均可按需填写留空时不提交该字段</text>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<view class="editor-control editor-control--unavailable"><text>服务端暂未提供可选择的分类项</text></view>
</view>
<view class="editor-field">
<text class="editor-field__label"><text class="required-mark">*</text>文章标题</text>
<view class="editor-control"><input v-model="form.articleTitle" placeholder="请输入文章标题" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field">
<text class="editor-field__label">文章摘要</text>
<view class="editor-control editor-control--textarea"><textarea v-model="form.articleSummary" auto-height placeholder="概括文章内容" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field editor-field--cover">
<view>
<text class="editor-field__label">封面图片</text>
<text class="editor-field__hint">选择图片后会取得真实上传回执并作为封面关联</text>
</view>
<button class="upload-button" :disabled="uploading || isSubmitting" @click="uploadCover">{{ uploading ? '上传中…' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
<text v-if="uploadError" class="editor-save-error">{{ uploadError }}</text>
</view>
<view class="editor-field">
<text class="editor-field__label"><text class="required-mark">*</text>正文内容</text>
<view class="editor-control editor-control--textarea editor-control--article"><textarea v-model="form.articleContent" auto-height placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<view class="editor-field">
<text class="editor-field__label">作者名称</text>
<view class="editor-control"><input v-model="form.authorName" placeholder="请输入作者名称" placeholder-class="editor-placeholder" @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 ? '正在提交' : '提交谱文'" :disabled="isSubmitting || uploading" @click="submit" />
</view>
</view>
<view v-else class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
<text class="editor-result-card__copy">{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃谱文草稿?"
message="当前内容尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</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, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const editorState = ref("form");
const genealogyId = ref("");
const isSubmitting = ref(false);
const uploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const form = reactive({
articleTitle: "",
articleSummary: "",
articleContent: "",
authorName: "",
sortOrder: "",
});
const requestController = createRequestController();
const isDirty = computed(() => Boolean(
Object.values(form).some((value) => value.trim()) || coverOssId.value,
));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? {
eyebrow: "服务端已接受",
title: "谱文已提交",
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
action: "返回谱文列表",
}
: {
eyebrow: "谱文入口无效",
title: "无法创建谱文",
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
action: "返回上一页",
},
);
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 || query?.mode !== "create") editorState.value = "invalid";
});
const uploadCover = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
}
} finally {
uploading.value = false;
}
};
const submit = async () => {
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
submitError.value = !form.articleTitle.trim() ? "请填写文章标题" : "请填写正文内容";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createArticle(genealogyId.value, {
...form,
coverOssId: coverOssId.value,
}, { requestController });
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
};
const requestBack = () => 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();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.article-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.article-editor-page__header, .article-editor-content { z-index: 1; }
.article-editor-content { padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom)); }
.editor-panel, .editor-result-card { @include adaptive.adaptive-family-panel; width: 100%; }
.editor-panel__body { padding: 38rpx 34rpx 42rpx; }
.editor-eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; text-align: center; }
.editor-title { display: block; margin-top: 10rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; text-align: center; }
.editor-intro { display: block; margin: 10rpx 8rpx 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; text-align: center; }
.editor-field { margin-top: 18rpx; }
.editor-field__label { display: block; margin: 0 8rpx 8rpx; 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); }
.editor-control { @include adaptive.adaptive-family-field; display: flex; min-height: 82rpx; align-items: center; }
.editor-control input, .editor-control textarea { box-sizing: border-box; width: 100%; color: $ink; font-size: 24rpx; }
.editor-control input { min-height: 82rpx; padding: 0 28rpx; }
.editor-control--textarea { min-height: 132rpx; padding: 20rpx 24rpx; }
.editor-control--article { min-height: 220rpx; }
.editor-control--unavailable { padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; }
.editor-control textarea { min-height: 92rpx; line-height: 1.65; }
.editor-control--article textarea { min-height: 180rpx; }
.editor-field--cover { display: grid; gap: 12rpx; padding: 18rpx 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.editor-field--cover .editor-field__label { margin: 0; }
.editor-field__hint { display: block; 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; }
.editor-placeholder { color: #9e8e79; }
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
.editor-panel__body > .app-button { margin-top: 22rpx; }
.editor-result-card { min-height: 420rpx; }
.editor-result-card__body { padding: 112rpx 52rpx 68rpx; text-align: center; }
.editor-result-card__title { display: block; margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.editor-result-card__copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.editor-result-card__body > .app-button { margin-top: 30rpx; }
</style>