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
+494
View File
@@ -0,0 +1,494 @@
<template>
<view class="publish-page" :class="`publish-state--${publishState}`">
<ModulePageBackground module="family" />
<view class="publish-page__header"
><PageHeader :title="isEdit ? '编辑动态' : '发布动态'" custom-back @back="requestBack"
/></view>
<view class="publish-panel">
<view v-if="publishState === 'form'" class="publish-form">
<text>{{ isEdit ? '编辑此刻' : '记录此刻' }}</text>
<text>{{ isEdit ? '原有图片和相关设置会保留。' : '填写要发布的内容,其余项目可按需补充。' }}</text>
<view class="publish-field publish-field--content">
<text class="publish-field__label"
><text class="required-mark">*</text>动态内容</text
>
<textarea
v-model="form.feedContent"
auto-height
placeholder="写下想对家人说的话"
placeholder-class="publish-placeholder"
@input="submitError = ''"
/>
</view>
<view class="publish-field">
<text class="publish-field__label">动态类型</text>
<view class="publish-field__value publish-field__value--readonly">
<text>文字动态</text>
<text>当前仅支持此类型</text>
</view>
</view>
<view class="publish-field publish-field--media">
<view>
<text class="publish-field__label">动态配图</text>
<text class="publish-field__hint"
>图片上传成功后会随动态一起发布</text
>
</view>
<button
class="upload-button"
:disabled="isUploading || isSubmitting"
@click="uploadImage"
>
{{ isUploading ? "上传中…" : "添加图片" }}
</button>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="upload-receipt"
>已上传{{ receipt.fileName || "图片" }}</text
>
<text v-if="uploadError" class="publish-error">{{
uploadError
}}</text>
</view>
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
<AppButton
block
:label="isSubmitting ? '正在提交' : isEdit ? '保存动态' : '提交动态'"
:disabled="isSubmitting || isUploading"
@click="saveFeed"
/>
</view>
<view v-else-if="publishState === 'loading'" class="publish-result"
><AppLoading text="正在读取动态"
/></view>
<view v-else class="publish-result">
<text>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton
block
:label="resultCopy.action"
@click="handleResultAction"
/>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃动态草稿?"
message="动态还没有发布,确认返回后将不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</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 { familyFeedApi } from "@/services/api/family-feed-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 genealogyId = ref("");
const form = reactive({ feedContent: "", feedType: "text" });
const mediaReceipts = ref([]);
const publishState = ref("loading");
const isSubmitting = ref(false);
const isUploading = ref(false);
const submitError = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const editingFeed = ref(null);
const editFeedId = ref("");
const formBaseline = ref("");
const submittedEdit = ref(false);
const feedDetailController = createRequestController();
const feedMediaUploadController = createRequestController();
const feedSaveController = createRequestController();
const feedCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const mediaOssIds = computed(() =>
mediaReceipts.value.map((item) => item.ossId).join(","),
);
const isEdit = computed(() => Boolean(editingFeed.value));
const formSnapshot = computed(() =>
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
);
const isDirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Boolean(form.feedContent.trim() || mediaReceipts.value.length),
);
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(
() =>
({
success: {
title: submittedEdit.value ? "动态已更新" : "动态发布成功",
copy: "已保存,返回后会显示最新内容。",
action: submittedEdit.value ? "返回动态详情" : "返回家族动态",
},
error: {
title: "动态未提交",
copy: submitError.value || "当前内容仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
title: "暂时无法打开动态",
copy: "未找到家谱信息,请返回家谱首页后重新进入。",
action: "返回上一页",
},
unavailable: {
title: "暂时无法编辑动态",
copy: "这条动态已经变化,暂未保存。",
action: "返回动态详情",
},
})[publishState.value],
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const resetForm = () => {
Object.assign(form, { feedContent: "", feedType: "text" });
mediaReceipts.value = [];
editingFeed.value = null;
formBaseline.value = "";
submitError.value = "";
uploadError.value = "";
};
const loadEditFeed = async (feedId) => {
try {
const detail = await familyFeedApi.getFeedDetail(genealogyId.value, feedId, {
requestController: feedDetailController,
});
if (!pageActive) return;
if (
!detail.canEdit ||
detail.type.toLowerCase() !== "text" ||
!["0", "1"].includes(detail.status) ||
!Number.isSafeInteger(detail.sortOrder)
) {
throw new Error("这条动态的信息不完整,暂未保存修改,以免覆盖原内容。");
}
resetForm();
Object.assign(form, { feedContent: detail.content, feedType: detail.type });
mediaReceipts.value = detail.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName,
}));
editingFeed.value = {
id: detail.id,
sortOrder: detail.sortOrder,
status: detail.status,
};
formBaseline.value = formSnapshot.value;
publishState.value = "form";
} catch (error) {
if (pageActive && !isRequestCancelled(error)) publishState.value = "unavailable";
}
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value) {
publishState.value = "invalid";
return;
}
if (query?.mode === "create") {
publishState.value = "form";
return;
}
const feedId = String(query?.feedId || "");
if (query?.mode === "edit" && /^[1-9]\d*$/.test(feedId)) {
editFeedId.value = feedId;
loadEditFeed(feedId);
return;
}
publishState.value = "invalid";
});
const uploadImage = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: feedMediaUploadController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const saveFeed = async () => {
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.feedContent.trim()) {
submitError.value = "请填写动态内容";
return;
}
const payload = {
feedContent: form.feedContent,
feedType: form.feedType,
mediaOssIds: mediaOssIds.value,
...(editingFeed.value
? {
sortOrder: editingFeed.value.sortOrder,
status: editingFeed.value.status,
}
: {}),
};
const createAttempt = editingFeed.value ? null : feedCreateGuard.begin(payload);
if (!editingFeed.value && createAttempt === null) {
publishState.value = "error";
submitError.value =
"上次发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
submittedEdit.value = Boolean(editingFeed.value);
if (editingFeed.value) {
await familyFeedApi.updateFeed(genealogyId.value, editingFeed.value.id, payload, {
requestController: feedSaveController,
});
} else {
await familyFeedApi.createFeed(genealogyId.value, payload, {
requestController: feedSaveController,
});
}
if (!pageActive) return;
resetForm();
publishState.value = "success";
} catch (error) {
if (!pageActive) return;
if (!editingFeed.value && feedCreateGuard.recordFailure(createAttempt, error)) {
publishState.value = "error";
submitError.value =
"发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
return;
}
if (isRequestCancelled(error)) return;
publishState.value = "error";
submitError.value = getRequestErrorMessage(error, "动态提交失败,请稍后重试。");
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "success") {
return submittedEdit.value
? returnTo("F03", {
genealogyId: genealogyId.value,
feedId: editFeedId.value,
})
: returnToFamily();
}
if (publishState.value === "error") {
publishState.value = "form";
return;
}
if (publishState.value === "unavailable") {
return returnTo("F03", {
genealogyId: genealogyId.value,
feedId: editFeedId.value,
});
}
return goBack();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
feedDetailController.abort();
feedMediaUploadController.abort();
feedSaveController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.publish-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.publish-page__header,
.publish-panel {
@include adaptive-family-content;
z-index: 1;
}
.publish-panel {
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 9%;
box-sizing: border-box;
}
.publish-form > text,
.publish-result > text {
display: block;
}
.publish-form > text:first-child,
.publish-result > text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 36rpx, 24px);
font-weight: 700;
}
.publish-form > text:nth-child(2),
.publish-result > text:nth-child(2) {
margin-top: 12rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.6;
}
.publish-field {
@include adaptive-family-field;
margin-top: 20rpx;
padding: 18rpx 22rpx;
}
.publish-field__label {
display: block;
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);
}
.publish-field input,
.publish-field textarea {
display: block;
box-sizing: border-box;
width: 100%;
margin-top: 12rpx;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.publish-field input {
min-height: 64rpx;
}
.publish-field__value--readonly {
display: flex;
min-height: 76rpx;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
padding: 0 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 10rpx;
background: rgba(255, 252, 245, 0.48);
}
.publish-field__value--readonly text:first-child {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.publish-field__value--readonly text:last-child {
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
}
.publish-field--content textarea {
min-height: 200rpx;
line-height: 1.7;
}
.publish-field--media {
display: grid;
gap: 12rpx;
}
.publish-field__hint {
display: block;
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.45;
}
.upload-button {
justify-self: start;
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;
}
.publish-placeholder {
color: #8e806e;
}
.publish-error {
margin-top: 12rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.publish-form > .app-button {
margin-top: 24rpx;
}
.publish-result {
margin-top: 20%;
text-align: center;
}
.publish-result > .app-button {
margin: 30rpx auto 0;
}
</style>