Files
jiapuapp/pages/family/article-editor.vue
T

620 lines
20 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="article-editor-page"
:class="`article-editor-state--${editorState}`"
>
<ModulePageBackground module="family" />
<view class="article-editor-page__header"
><PageHeader :title="isEdit ? '编辑谱文' : '新建谱文'" custom-back @back="requestBack"
/></view>
<view class="article-editor-content">
<AppLoading v-if="editorState === 'loading'" text="正在读取谱文" />
<view v-else-if="editorState === 'form'" class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">{{ isEdit ? '编辑谱文' : '新建谱文' }}</text>
<text class="editor-title">{{ isEdit ? '更新这篇传承故事' : '把值得传承的故事写下来' }}</text>
<text class="editor-intro"
>{{ isEdit ? '未重新选择封面时,原封面会保留。' : '除标题和正文外,其他内容可按需填写。' }}</text
>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<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"
><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
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || isSubmitting" @click="clearCover">移除封面</button>
<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>
<text v-if="submitError" class="editor-save-error">{{
submitError
}}</text>
<AppButton
block
:label="isSubmitting ? '正在提交' : isEdit ? '保存修改' : '提交谱文'"
:disabled="isSubmitting || uploading"
@click="saveArticle"
/>
</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, 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 { 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/media-upload.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} 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);
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: "",
});
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 resultCopy = computed(() =>
editorState.value === "success"
? {
eyebrow: "保存成功",
title: isEdit.value ? "谱文已更新" : "谱文已提交",
copy: "已保存,返回后会显示最新内容。",
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
}
: editorState.value === "error"
? {
eyebrow: "谱文暂不可编辑",
title: "这篇谱文暂时无法编辑",
copy: submitError.value || "请返回后重新查看。",
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 || "");
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: articleCoverUploadController,
});
if (!pageActive) return;
coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
}
} finally {
if (pageActive) uploading.value = false;
}
};
const clearCover = () => {
if (uploading.value || isSubmitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const saveArticle = async () => {
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
submitError.value = !form.articleTitle.trim()
? "请填写文章标题"
: "请填写正文内容";
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 {
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 = getRequestErrorMessage(error, "谱文提交失败,请稍后重试。");
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const requestBack = () =>
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"
? 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));
onUnload(() => {
pageActive = false;
articleDetailController.abort();
articleCategoryController.abort();
articleCoverUploadController.abort();
articleSaveController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.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-family-panel;
width: 100%;
background-color: rgba($paper, 0.82);
}
.editor-panel__body {
padding: 38rpx 34rpx 42rpx;
}
.editor-eyebrow {
display: block;
color: $brand-red;
font-size: clamp(14px, 23rpx, 17px);
letter-spacing: 3rpx;
text-align: center;
}
.editor-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
text-align: center;
overflow-wrap: anywhere;
}
.editor-intro {
display: block;
margin: 10rpx 8rpx 22rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
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: 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);
}
.editor-control {
@include 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: clamp(15px, 24rpx, 18px);
}
.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: clamp(14px, 23rpx, 17px);
}
.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, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.editor-field--cover .editor-field__label {
margin: 0;
}
.editor-field__hint {
display: block;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.45;
}
.upload-button {
justify-self: start;
min-height: 88rpx;
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;
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.editor-placeholder {
color: #9e8e79;
}
.editor-save-error {
display: block;
margin: 12rpx 8rpx 0;
color: $brand-red;
font-size: clamp(15px, 24rpx, 18px);
line-height: max(1.35em, clamp(19px, 34rpx, 24px));
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: clamp(19px, 36rpx, 24px);
font-weight: 700;
}
.editor-result-card__copy {
display: block;
margin-top: 16rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.65;
}
.editor-result-card__body > .app-button {
margin-top: 30rpx;
}
</style>