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

799 lines
26 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 editor-field--media">
<view>
<text class="editor-field__label">正文图片</text>
<text class="editor-field__hint">图片按添加顺序显示可单独移除</text>
</view>
<button
class="upload-button"
:disabled="uploading || isSubmitting"
@click="uploadArticleMedia"
>
{{ uploading ? "上传中…" : "添加正文图片" }}
</button>
<view v-if="mediaReceipts.length" class="body-media-list">
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="body-media-item"
>
<text>{{ index + 1 }}. {{ receipt.fileName || "正文图片" }}</text>
<button
class="remove-media-button"
:disabled="uploading || isSubmitting"
:aria-label="`移除第${index + 1}张正文图片`"
@click="removeArticleMedia(index)"
>移除</button>
</view>
</view>
<text v-if="mediaUploadError" class="editor-save-error">{{ mediaUploadError }}</text>
</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 v-if="!isEdit" class="editor-field">
<text class="editor-field__label">内容密码选填</text>
<text class="editor-field__hint">填写后会先创建谱文再立即启用密码保护密码设置失败时会明确提示</text>
<view class="editor-control">
<input
v-model="contentPassword"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
placeholder-class="editor-placeholder"
@input="submitError = ''"
/>
</view>
<view class="editor-control editor-control--password-confirm">
<input
v-model="contentPasswordConfirm"
password
maxlength="128"
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 mediaUploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const mediaReceipts = ref([]);
const contentPassword = ref("");
const contentPasswordConfirm = ref("");
const passwordSetupError = ref("");
const passwordProtectionEnabled = ref(false);
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 articleMediaUploadController = createRequestController();
const articleSaveController = createRequestController();
const articlePasswordController = 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 mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() =>
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
);
const isDirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Boolean(
Object.values(form).some((value) => value.trim()) ||
coverOssId.value ||
mediaReceipts.value.length ||
contentPassword.value ||
contentPasswordConfirm.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"
? passwordSetupError.value
? {
eyebrow: "谱文已创建",
title: "密码保护尚未确认",
copy: passwordSetupError.value,
action: "打开谱文详情",
}
: {
eyebrow: "保存成功",
title: isEdit.value ? "谱文已更新" : "谱文已提交",
copy: !isEdit.value && passwordProtectionEnabled.value
? "谱文已保存,并已启用内容密码。"
: "已保存,返回后会显示最新内容。",
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 || "当前封面图片"
: "";
mediaReceipts.value = article.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.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 uploadArticleMedia = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
mediaUploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: articleMediaUploadController,
});
if (!pageActive) return;
if (mediaReceipts.value.some((item) => item.ossId === receipt.ossId)) {
mediaUploadError.value = "这张图片已经添加。";
return;
}
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
mediaUploadError.value = getRequestErrorMessage(error, "正文图片上传失败,请稍后重试。");
}
} finally {
if (pageActive) uploading.value = false;
}
};
const removeArticleMedia = (index) => {
if (uploading.value || isSubmitting.value || !Number.isInteger(index)) return;
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
mediaUploadError.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,
mediaOssIds: mediaOssIds.value,
...(isEdit.value ? preservedUpdateFields.value : {}),
};
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
if (!isEdit.value && createAttempt === null) {
submitError.value =
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
return;
}
if (contentPassword.value || contentPasswordConfirm.value) {
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
submitError.value = "内容密码必须为8至128位。";
return;
}
if (contentPassword.value !== contentPasswordConfirm.value) {
submitError.value = "两次输入的内容密码不一致。";
return;
}
}
isSubmitting.value = true;
submitError.value = "";
passwordSetupError.value = "";
try {
if (isEdit.value) {
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
requestController: articleSaveController,
});
} else {
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
requestController: articleSaveController,
});
if (!pageActive) return;
articleId.value = createdArticle.id;
if (contentPassword.value) {
try {
await familyArticleApi.setArticlePassword(
genealogyId.value,
createdArticle.id,
contentPassword.value,
{ requestController: articlePasswordController },
);
passwordProtectionEnabled.value = true;
} catch (passwordError) {
if (!pageActive || isRequestCancelled(passwordError)) return;
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
passwordSetupError.value = `谱文已经创建,但${failureCopy}。请进入详情重新设置;当前内容可能尚未受到密码保护。`;
}
contentPassword.value = "";
contentPasswordConfirm.value = "";
}
}
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 || passwordSetupError.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();
articleMediaUploadController.abort();
articleSaveController.abort();
articlePasswordController.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,
.editor-field--media {
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,
.editor-field--media .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;
}
.editor-control--password-confirm { margin-top: 14rpx; }
.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; }
.body-media-list {
display: grid;
gap: 8rpx;
}
.body-media-item {
display: flex;
min-height: 66rpx;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 8rpx 12rpx 8rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 8rpx;
background: rgba(255, 253, 248, 0.58);
}
.body-media-item > text {
min-width: 0;
flex: 1;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-media-button {
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin: 0;
padding: 0 14rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
}
.remove-media-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>