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

479 lines
13 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用途新建与编辑谱文 -->
<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 v-if="editorState === 'loading'" class="article-editor-loading">
<AppLoading
text="正在打开谱文编辑器"
description="请稍候,草稿正在展开。"
/>
</view>
<view
v-else-if="editorState === 'preview' || editorState === 'invalid'"
class="article-editor-content"
>
<view 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 v-else class="article-editor-content">
<view 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"
:class="{ 'editor-control--error': fieldErrors.title }"
>
<input
v-model="form.title"
maxlength="40"
placeholder="请输入文章标题"
placeholder-class="editor-placeholder"
@input="clearFieldError('title')"
/>
</view>
<text v-if="fieldErrors.title" class="editor-field__error">{{
fieldErrors.title
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }"
>
<input
v-model="form.category"
maxlength="20"
placeholder="如:家风家训"
placeholder-class="editor-placeholder"
@input="clearFieldError('category')"
/>
</view>
<text v-if="fieldErrors.category" class="editor-field__error">{{
fieldErrors.category
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view
class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }"
>
<textarea
v-model="form.content"
auto-height
maxlength="1200"
placeholder="记录家训、往事或想留给后人的话"
placeholder-class="editor-placeholder"
@input="clearFieldError('content')"
/>
</view>
<text v-if="fieldErrors.content" class="editor-field__error">{{
fieldErrors.content
}}</text>
</view>
<text class="editor-draft-note"
>当前为未发布草稿真实草稿保存将在接口阶段接入</text
>
<text v-if="editorState === 'error'" class="editor-save-error"
>保存失败已填写内容仍保留请稍后重试</text
>
<AppButton
block
:label="actionLabel"
:disabled="isSubmitting"
@click="submit"
/>
</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 {
findFamilyArticleFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const draftFixture = {
title: "汤氏家训辑录",
category: "家风家训",
content:
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const genealogyId = ref("");
const articleId = ref("");
const editorMode = ref("create");
const isSubmitting = ref(false);
const discardVisible = ref(false);
const allowedStates = new Set([
"draft",
"loading",
"validation",
"error",
"preview",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
const baseline = ref("");
let saveTimer = null;
const actionLabel = computed(() =>
isSubmitting.value
? "正在校验…"
: editorState.value === "error"
? "重新校验"
: "生成本地预览",
);
const formSnapshot = computed(() => JSON.stringify(form));
const isDirty = computed(() =>
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
);
const hasValidContext = computed(() =>
Boolean(
genealogyId.value &&
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
) &&
(editorMode.value === "create" ||
(editorMode.value === "edit" && articleId.value)),
),
);
const resultCopy = computed(() =>
editorState.value === "preview"
? {
eyebrow: "本地流程预览",
title: "谱文内容已通过本地校验",
copy: "当前尚未提交服务器,返回后不会新增或修改谱文。",
action:
editorMode.value === "edit"
? "返回原谱文(不保存)"
: "返回谱文列表(不保存)",
}
: {
eyebrow: "谱文入口无效",
title: "没有找到要编辑的谱文上下文",
copy: "请从当前家谱的谱文列表重新进入,页面不会创建无归属内容。",
action: "返回上一页",
},
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const fillDraft = () => {
Object.assign(form, draftFixture);
};
const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题";
fieldErrors.category = "请填写文章分类";
fieldErrors.content = "请填写正文内容";
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
articleId.value = String(query.articleId || "");
editorMode.value = String(query.mode || "");
const article =
editorMode.value === "edit"
? findFamilyArticleFixture(genealogyId.value, articleId.value)
: null;
const modeIsValid =
(editorMode.value === "create" && !articleId.value) ||
(editorMode.value === "edit" && Boolean(article));
if (!modeIsValid || !hasValidContext.value) {
editorState.value = "invalid";
return;
}
const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (article) {
Object.assign(form, {
title: article.title,
category: article.category,
content: article.paragraphs.join("\n\n"),
});
} else if (["draft", "error", "preview"].includes(requestedState)) {
fillDraft();
}
baseline.value = formSnapshot.value;
if (editorMode.value === "create" && requestedState === "draft") {
baseline.value = JSON.stringify({ title: "", category: "", content: "" });
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
if (
editorState.value === "error" ||
editorState.value === "validation"
) {
editorState.value = "form";
}
};
const validate = () => {
fieldErrors.title = form.title.trim() ? "" : "请填写文章标题";
fieldErrors.category = form.category.trim() ? "" : "请填写文章分类";
fieldErrors.content = form.content.trim() ? "" : "请填写正文内容";
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!validate()) {
editorState.value = "validation";
return;
}
isSubmitting.value = true;
const submitSnapshot = formSnapshot.value;
const timer = setTimeout(() => {
if (saveTimer !== timer) return;
editorState.value = submitSnapshot ? "preview" : "error";
isSubmitting.value = false;
saveTimer = null;
}, 320);
saveTimer = timer;
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToTarget = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return editorMode.value === "edit"
? returnTo("F05", {
genealogyId: genealogyId.value,
articleId: articleId.value,
})
: returnTo("F04", { genealogyId: genealogyId.value });
};
const handleResultAction = () =>
editorState.value === "preview" ? returnToTarget() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
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-loading,
.article-editor-content {
z-index: 1;
}
.article-editor-loading {
padding-top: 150rpx;
}
.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;
}
.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 {
width: 100%;
min-height: 82rpx;
padding: 0 28rpx;
}
.editor-control--textarea {
min-height: 220rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.editor-control textarea {
width: 100%;
min-height: 180rpx;
line-height: 1.65;
}
.editor-control--error {
filter: sepia(0.18) saturate(1.2);
}
.editor-placeholder {
color: #9e8e79;
}
.editor-field__error,
.editor-save-error {
display: block;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
}
.editor-field__error {
margin: 5rpx 8rpx 0;
}
.editor-draft-note {
display: block;
margin: 20rpx 10rpx 0;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
text-align: center;
}
.editor-save-error {
margin: 12rpx 8rpx 0;
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;
}
@media (max-width: 340px) {
.article-editor-content {
padding-right: 14rpx;
padding-left: 14rpx;
}
.editor-panel__body {
padding-right: 26rpx;
padding-left: 26rpx;
}
}
</style>