feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+597
View File
@@ -0,0 +1,597 @@
<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
>
<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 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%;
}
.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;
}
.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;
}
.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>