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
+729
View File
@@ -0,0 +1,729 @@
<template>
<view class="poem-page">
<GenealogyPageBackground />
<view class="poem-page__header">
<PageHeader
:title="pageTitle"
:action="poemState === 'list' || poemState === 'empty' ? '维护' : ''"
custom-back
@action="enterManagement"
@back="requestBack"
/>
</view>
<view class="poem-panel" :class="`poem-state--${poemState}`">
<AppLoading
v-if="poemState === 'loading'"
text="正在读取字辈诗"
description="正在整理家谱的传承字序。"
/>
<view v-else-if="poemState === 'list'" class="poem-list">
<text class="poem-list__eyebrow">{{ genealogyName }} · 传承字序</text>
<text class="poem-list__title">{{ generationRangeTitle }}</text>
<text class="poem-list__copy">按已记录的世代和字辈展示传承字序</text>
<view class="poem-rows">
<view
v-for="item in visiblePoemRows"
:key="item.poemId"
class="poem-row"
:class="{
'poem-row--disabled':
item.status === GENERATION_POEM_STATUS.DISABLED,
}"
>
<text class="poem-row__number"> {{ item.generationNo }} </text>
<text class="poem-row__character">{{ item.generationText }}</text>
<text class="poem-row__status">{{
item.status === GENERATION_POEM_STATUS.DISABLED
? "已停用·记录保留"
: "传承字序"
}}</text>
</view>
</view>
<view
v-if="remainingPoemCount > 0"
class="poem-load-more"
role="button"
:aria-label="'继续加载后续字辈剩余 ' + remainingPoemCount + ' '"
@click="loadMorePoems"
>
<text>继续加载后续字辈剩余 {{ remainingPoemCount }} </text>
</view>
<view
class="poem-action"
role="button"
:aria-label="canManage ? '继续维护字辈诗' : '查看字辈列表'"
@click="enterManagement"
>
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="aspectFit"
/><text>{{ canManage ? "继续维护字辈诗" : "查看字辈列表" }}</text>
</view>
</view>
<view v-else-if="poemState === 'edit'" class="poem-editor">
<text class="poem-list__eyebrow">统一维护</text>
<text class="poem-list__title">填写完整字辈</text>
<text class="poem-list__copy"
>保存前可先查看调整结果没有分隔符时每个字对应一代多个字可用空格逗号分号顿号斜杠或竖线分开</text
>
<view class="poem-field">
<text>字辈内容</text>
<textarea
v-model="poemDraft"
auto-height
:disabled="saving"
:maxlength="MAX_GENERATION_POEM_INPUT_LENGTH"
placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后"
placeholder-class="poem-placeholder"
@input="invalidatePreview"
/>
</view>
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
<view class="poem-policy">
<text>这次未填写到的后续字辈</text>
<view
class="poem-policy__option"
:class="{ 'poem-policy__option--disabled': saving }"
role="checkbox"
:aria-checked="disableMissing"
:aria-disabled="saving"
aria-label="停用未填写到的后续字辈"
@click="toggleDisableMissing"
>
<image
:src="
disableMissing
? '/static/assets/foundation/transparent/scroll-primary.png'
: '/static/assets/foundation/transparent/scroll-secondary.png'
"
mode="aspectFit"
/>
<text
:class="{ 'poem-policy__option-text--active': disableMissing }"
>{{ disableMissing ? "停用并保留记录" : "保持原状态" }}</text
>
</view>
</view>
<text class="poem-preview">{{ previewSummary }}</text>
<view class="poem-editor__actions">
<view
class="poem-action"
:class="{ 'poem-action--disabled': previewing || saving }"
role="button"
:aria-disabled="previewing || saving"
aria-label="取消"
@click="requestLeaveEditor"
>
<image
src="/static/assets/foundation/transparent/scroll-secondary.png"
mode="aspectFit"
/><text class="poem-action__secondary">取消</text>
</view>
<view
class="poem-action"
:class="{ 'poem-action--disabled': previewing || saving }"
role="button"
:aria-disabled="previewing || saving"
aria-label="查看调整结果"
@click="previewPoems"
>
<image
src="/static/assets/foundation/transparent/scroll-secondary.png"
mode="aspectFit"
/><text class="poem-action__secondary">{{
previewing ? "正在查看" : "查看调整结果"
}}</text>
</view>
<view
class="poem-action"
:class="{ 'poem-action--disabled': !canSave || saving }"
role="button"
:aria-disabled="!canSave || saving"
aria-label="保存字辈"
@click="savePoems"
>
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="aspectFit"
/><text>{{ saving ? "正在保存" : "保存字辈" }}</text>
</view>
</view>
</view>
<view v-else class="poem-state-card">
<text class="poem-list__eyebrow">{{
poemState === "empty" ? "尚未建立字辈" : "字辈暂不可用"
}}</text>
<text class="poem-list__title">{{
poemState === "empty"
? "暂时没有可显示的字辈"
: "暂时无法读取字辈诗"
}}</text>
<text class="poem-list__copy">{{ stateCopy }}</text>
<view
class="poem-action"
role="button"
:aria-label="poemState === 'empty' ? '查看字辈列表' : '重新读取'"
@click="handleStateAction"
>
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="aspectFit"
/><text>{{
poemState === "empty" ? "查看字辈列表" : "重新读取"
}}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃字辈修改?"
message="当前修改还没有保存,确认返回后将不会保留。"
confirm-text="放弃修改"
cancel-text="继续编辑"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<view v-if="feedbackMessage" class="poem-feedback"
><text>{{ feedbackMessage }}</text></view
>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { generationPoemApi } from "@/services/api/generation-poem-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
MAX_GENERATION_POEM_INPUT_LENGTH,
GENERATION_POEM_STATUS,
validateGenerationPoemText,
} from "@/utils/genealogy/generation-poem.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const POEM_RENDER_BATCH_SIZE = 50;
const genealogyId = ref("");
const genealogyName = ref("当前家谱");
const poemState = ref("loading");
const poemError = ref("");
const feedbackMessage = ref("");
const discardVisible = ref(false);
const canManage = ref(false);
const previewing = ref(false);
const saving = ref(false);
const poemDraft = ref("");
const disableMissing = ref(false);
const editorSnapshot = ref(null);
const poemRows = ref([]);
const visiblePoemCount = ref(POEM_RENDER_BATCH_SIZE);
const preview = ref(null);
const previewSignature = ref("");
const poemListController = createRequestController();
const poemPreviewController = createRequestController();
const poemSaveController = createRequestController();
let requestSequence = 0;
let pageActive = true;
const visiblePoemRows = computed(() =>
poemRows.value.slice(0, visiblePoemCount.value),
);
const remainingPoemCount = computed(() =>
Math.max(0, poemRows.value.length - visiblePoemCount.value),
);
const pageTitle = computed(() =>
poemState.value === "edit" ? "维护字辈诗" : "字辈诗",
);
const generationRangeTitle = computed(() => {
if (!poemRows.value.length) return "尚未建立字辈";
const first = poemRows.value[0].generationNo;
const last = poemRows.value[poemRows.value.length - 1].generationNo;
return first === last ? `${first} 世字辈` : `${first}${last} 世字辈`;
});
const draftSignature = computed(
() => `${poemDraft.value}\u0000${disableMissing.value}`,
);
const canSave = computed(
() =>
Boolean(preview.value) && previewSignature.value === draftSignature.value,
);
const previewSummary = computed(() => {
if (previewing.value) return "正在查看调整结果。";
if (!preview.value) return "请先查看调整结果,再保存。";
return `调整结果:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
});
const stateCopy = computed(() =>
poemState.value === "empty"
? "当前尚未添加可用的字辈,可进入维护页面查看。"
: "暂未读取到可展示的字辈,请稍后重试。",
);
const isDirty = computed(() =>
Boolean(
poemState.value === "edit" &&
editorSnapshot.value &&
(poemDraft.value !== editorSnapshot.value.poemDraft ||
disableMissing.value !== editorSnapshot.value.disableMissing),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const applyRows = (rows) => {
poemRows.value = rows;
visiblePoemCount.value = POEM_RENDER_BATCH_SIZE;
const named = rows.find((item) => item.genealogyName);
genealogyName.value = named?.genealogyName || "当前家谱";
};
const loadPoems = async ({ management = false } = {}) => {
if (!genealogyId.value) {
poemState.value = "error";
return false;
}
const sequence = ++requestSequence;
poemState.value = "loading";
poemError.value = "";
try {
const rows = management
? await generationPoemApi.getGenerationPoemManagement(genealogyId.value, {
requestController: poemListController,
})
: await generationPoemApi.getGenerationPoems(genealogyId.value, {
requestController: poemListController,
});
if (!pageActive || sequence !== requestSequence) return false;
applyRows(rows);
canManage.value = management;
poemState.value = rows.length ? "list" : "empty";
return true;
} catch (error) {
if (!pageActive || isRequestCancelled(error) || sequence !== requestSequence) return false;
poemState.value = "error";
return false;
}
};
const loadMorePoems = () => {
visiblePoemCount.value = Math.min(
poemRows.value.length,
visiblePoemCount.value + POEM_RENDER_BATCH_SIZE,
);
};
const invalidatePreview = () => {
poemError.value = "";
preview.value = null;
previewSignature.value = "";
};
const toggleDisableMissing = () => {
if (saving.value) return;
disableMissing.value = !disableMissing.value;
invalidatePreview();
};
const openEditor = () => {
if (!canManage.value) return false;
poemDraft.value = poemRows.value
.filter((item) => item.status === GENERATION_POEM_STATUS.ACTIVE)
.map((item) => item.generationText)
.join(" ");
disableMissing.value = false;
preview.value = null;
previewSignature.value = "";
editorSnapshot.value = Object.freeze({
poemDraft: poemDraft.value,
disableMissing: disableMissing.value,
});
poemState.value = "edit";
return true;
};
const enterManagement = async () => {
if (previewing.value || saving.value) return;
const loaded = await loadPoems({ management: true });
if (pageActive && loaded) openEditor();
};
const requestLeaveEditor = async () => {
if (previewing.value || saving.value) return false;
if (isDirty.value) {
const confirmed = await requestDiscardConfirmation();
if (!confirmed) return false;
}
poemState.value = poemRows.value.length ? "list" : "empty";
editorSnapshot.value = null;
poemError.value = "";
return true;
};
const validateDraft = () => {
const validation = validateGenerationPoemText(poemDraft.value);
if (!validation.valid) {
poemError.value = validation.message;
return false;
}
return true;
};
const previewPoems = async () => {
if (previewing.value || saving.value || !validateDraft()) return;
const submittedDraft = {
poemText: poemDraft.value,
disableMissing: disableMissing.value,
};
const submittedSignature = draftSignature.value;
previewing.value = true;
poemError.value = "";
try {
const previewResult = await generationPoemApi.previewGenerationPoemBatch(
genealogyId.value,
submittedDraft,
{ requestController: poemPreviewController },
);
if (!pageActive || submittedSignature !== draftSignature.value) return;
preview.value = previewResult;
previewSignature.value = submittedSignature;
} catch (error) {
if (pageActive && !isRequestCancelled(error))
poemError.value = getRequestErrorMessage(error, "暂时无法查看调整结果,请稍后重试。");
} finally {
if (pageActive) previewing.value = false;
}
};
const savePoems = async () => {
if (!canSave.value || saving.value || !validateDraft()) return;
saving.value = true;
poemError.value = "";
try {
await generationPoemApi.saveGenerationPoemBatch(
genealogyId.value,
{
poemText: poemDraft.value,
disableMissing: disableMissing.value,
},
{ requestController: poemSaveController },
);
if (!pageActive) return;
feedbackMessage.value = "字辈已保存,正在刷新列表。";
const loaded = await loadPoems({ management: true });
if (!pageActive) return;
if (!loaded) {
poemError.value = "字辈已保存,但页面暂时未更新,请稍后重新查看。";
return;
}
editorSnapshot.value = null;
feedbackMessage.value = "字辈已保存。";
} catch (error) {
if (pageActive && !isRequestCancelled(error))
poemError.value = getRequestErrorMessage(error, "字辈保存失败,请稍后重试。");
} finally {
if (pageActive) saving.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
internalTrail: poemState.value === "edit",
submitting: previewing.value || saving.value,
"close-transient": cancelDiscard,
"pop-internal-trail": requestLeaveEditor,
"block-submitting": () => true,
});
const handleStateAction = () =>
poemState.value === "empty" ? enterManagement() : loadPoems();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
loadPoems();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
++requestSequence;
poemListController.abort();
poemPreviewController.abort();
poemSaveController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.poem-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.poem-page__header {
z-index: 3;
}
.poem-panel {
@include adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
padding: 76rpx 8%;
box-sizing: border-box;
}
.poem-panel > .app-loading {
grid-area: 1 / 1 / -1 / -1;
}
.poem-list,
.poem-editor,
.poem-state-card {
min-width: 0;
}
.poem-list__eyebrow {
display: block;
color: $brand-red;
font-size: clamp(14px, 23rpx, 17px);
letter-spacing: 3rpx;
}
.poem-list__title {
display: block;
margin-top: 11rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(19px, 35rpx, 24px);
font-weight: 700;
}
.poem-list__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.55;
}
.poem-rows {
margin-top: 18rpx;
}
.poem-load-more {
display: flex;
min-height: 88rpx;
align-items: center;
justify-content: center;
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/scroll-secondary.png")
center / contain no-repeat;
}
.poem-load-more text {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.poem-row {
@include adaptive-genealogy-form-field;
display: grid;
min-height: 72rpx;
margin-top: 10rpx;
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
align-items: center;
}
.poem-row__number {
z-index: 1;
grid-column: 1;
margin-left: 24rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
}
.poem-row__character {
z-index: 1;
grid-column: 2;
min-width: 0;
padding: 12rpx 16rpx 12rpx 0;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
}
.poem-row__status {
z-index: 1;
grid-column: 3;
margin-right: 22rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
}
.poem-row--disabled .poem-row__character,
.poem-row--disabled .poem-row__status {
color: $ink-muted;
opacity: 0.62;
}
.poem-action {
display: grid;
width: 100%;
min-height: 88rpx;
margin-top: 20rpx;
}
.poem-action--disabled {
opacity: 0.55;
}
.poem-action image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-action text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: #fff9ed;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 700;
letter-spacing: 2rpx;
}
.poem-field {
@include adaptive-genealogy-form-field;
display: grid;
min-height: 118rpx;
margin-top: 22rpx;
}
.poem-field > text {
z-index: 1;
grid-area: 1 / 1;
align-self: center;
margin-left: 24rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
}
.poem-field textarea {
z-index: 1;
grid-area: 1 / 1;
box-sizing: border-box;
width: auto;
min-width: 0;
min-height: 118rpx;
margin-right: 20rpx;
margin-left: 164rpx;
padding-top: 34rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.poem-placeholder {
color: #a79884;
}
.poem-field-error {
display: block;
margin-top: 4rpx;
color: $brand-red;
font-size: clamp(15px, 24rpx, 18px);
line-height: max(1.35em, clamp(19px, 34rpx, 24px));
text-align: right;
}
.poem-policy {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22rpx;
}
.poem-policy > text {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.poem-policy__option {
display: grid;
width: 248rpx;
min-height: 88rpx;
}
.poem-policy__option--disabled {
opacity: 0.55;
}
.poem-policy__option image {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.poem-policy__option text {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.poem-policy__option .poem-policy__option-text--active {
color: #fff9ed;
}
.poem-preview {
display: block;
margin-top: 22rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.6;
}
.poem-editor__actions {
display: flex;
gap: 14rpx;
margin-top: 18rpx;
}
.poem-editor__actions .poem-action {
width: calc((100% - 28rpx) / 3);
margin-top: 0;
}
.poem-action .poem-action__secondary {
color: $ink;
}
.poem-state-card {
padding: 180rpx 4% 70rpx;
text-align: center;
}
.poem-state-card .poem-list__eyebrow {
text-align: center;
}
.poem-state-card .poem-list__copy {
margin-top: 22rpx;
}
.poem-state-card .poem-action {
width: 420rpx;
max-width: 100%;
margin: 34rpx auto 0;
}
.poem-feedback {
position: fixed;
z-index: 30;
top: 138rpx;
left: 50%;
display: flex;
min-width: 260rpx;
min-height: 74rpx;
align-items: center;
justify-content: center;
padding: 0 34rpx;
transform: translateX(-50%);
background: url("/static/assets/foundation/transparent/scroll-secondary.png")
center / contain no-repeat;
}
.poem-feedback text {
z-index: 1;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
@media (min-width: 400px) {
.poem-panel {
width: calc(100% - 48rpx);
}
}
</style>