feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+2 -2
View File
@@ -233,7 +233,7 @@ onUnload(() => {
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.review-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.page-content { padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom)); }
.state-card, .review-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.state-card text, .review-card__phone, .review-card__relation, .review-card__reason, .page-feedback { display: block; }
@@ -245,7 +245,7 @@ onUnload(() => {
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
.review-card { padding: 28rpx 30rpx; }
.review-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
.review-card__heading text:first-child { min-width: 0; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
.review-card__heading text:first-child { min-width: 0; flex: 1; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
.review-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
.review-card__phone, .review-card__relation, .review-card__reason { margin-top: 12rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
.review-card__reason { color: #725840; }
+114
View File
@@ -0,0 +1,114 @@
<template>
<view class="capability-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="资料与回收站" custom-back @back="goBack" /></view>
<view class="page-content">
<view class="tab-row">
<button :class="['tab', { active: tab === 'completeness' }]" @click="tab = 'completeness'">资料完整度</button>
<button :class="['tab', { active: tab === 'recycle' }]" @click="tab = 'recycle'">内容回收站</button>
</view>
<view v-if="loading" class="state-card"><AppLoading text="正在读取家谱资料" /></view>
<view v-else-if="error" class="state-card"><text>{{ error }}</text><AppButton block label="重新加载" @click="load" /></view>
<view v-else-if="tab === 'completeness' && !hasValidCompleteness" class="state-card"><text>{{ completenessError || '资料完整度暂时无法读取。' }}</text><AppButton block label="重新加载" @click="load" /></view>
<view v-else-if="tab === 'completeness'" class="panel">
<text class="panel-title">资料完成度 {{ completeness?.completionRate || 0 }}%</text>
<text class="panel-copy">已完成 {{ completeness?.completedCount || 0 }} / {{ completeness?.totalCount || 0 }} </text>
<view v-if="!completeness?.missingItems?.length" class="empty-copy">这部家谱的基础资料已补充完整</view>
<view v-for="item in completeness?.missingItems || []" :key="item.code" class="row"><text>{{ item.displayText }}</text><text>待补充</text></view>
</view>
<view v-else-if="recycleError" class="state-card"><text>{{ recycleError }}</text><AppButton block label="重新加载" @click="load" /></view>
<view v-else class="panel">
<text class="panel-title">已删除内容</text>
<text class="panel-copy">可恢复的内容会显示恢复按钮</text>
<view v-if="!recycle.rows.length" class="empty-copy">回收站暂时没有内容</view>
<view v-for="item in recycle.rows" :key="`${item.resourceType}-${item.resourceId}`" class="row row--recycle">
<view><text>{{ item.resourceTitle || '未命名内容' }}</text><text class="row-meta">{{ item.resourceType }} · {{ item.deletedAt || '删除时间未知' }}</text></view>
<AppButton v-if="item.canRestore" class="restore-button" compact type="secondary" :disabled="restoringKey === `${item.resourceType}-${item.resourceId}`" label="恢复" @click="restore(item)" />
<text v-else class="row-meta">{{ recycleDisabledReason(item.disabledReason) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import AppButton from '@/components/AppButton.vue'
import AppLoading from '@/components/AppLoading.vue'
import PageHeader from '@/components/PageHeader.vue'
import GenealogyPageBackground from '@/components/genealogy/PageBackground.vue'
import { genealogyCapabilityApi } from '@/services/api/genealogy-capability-service.js'
import { createRequestController, isRequestCancelled } from '@/services/api/request-controller.js'
import { getRequestErrorMessage } from '@/services/api/request-error-message.js'
import { goBack } from '@/utils/navigation/gateway.js'
const genealogyId = ref('')
const tab = ref('completeness')
const loading = ref(true)
const error = ref('')
const completenessError = ref('')
const recycleError = ref('')
const completeness = ref(null)
const recycle = ref({ rows: [], total: 0 })
const restoringKey = ref('')
const controller = createRequestController()
const recycleDisabledReason = (reason) => reason === 'ALREADY_RESTORED' ? '已恢复' : '不可恢复'
const hasValidCompleteness = computed(() => (
Number.isSafeInteger(completeness.value?.totalCount) &&
completeness.value.totalCount > 0 &&
Number.isSafeInteger(completeness.value?.completedCount) &&
completeness.value.completedCount >= 0 &&
completeness.value.completedCount <= completeness.value.totalCount &&
Number.isSafeInteger(completeness.value?.completionRate) &&
completeness.value.completionRate >= 0 &&
completeness.value.completionRate <= 100 &&
Array.isArray(completeness.value?.missingItems)
))
const load = async () => {
if (!/^[1-9]\d*$/.test(genealogyId.value)) { error.value = '缺少有效家谱信息'; loading.value = false; return }
controller.abort(); loading.value = true; error.value = ''; completenessError.value = ''; recycleError.value = ''
const [completenessResult, recycleResult] = await Promise.allSettled([
genealogyCapabilityApi.getCompleteness(genealogyId.value, { requestController: controller }),
genealogyCapabilityApi.getRecyclePage(genealogyId.value, { pageNum: 1, pageSize: 50, recycleStatus: 'OPEN' }, { requestController: controller })
])
if (completenessResult.status === 'fulfilled') completeness.value = completenessResult.value
else if (!isRequestCancelled(completenessResult.reason)) {
completeness.value = null
completenessError.value = getRequestErrorMessage(completenessResult.reason, '资料完整度暂时无法读取。')
}
if (recycleResult.status === 'fulfilled') recycle.value = recycleResult.value
else if (!isRequestCancelled(recycleResult.reason)) {
recycle.value = { rows: [], total: 0 }
recycleError.value = getRequestErrorMessage(recycleResult.reason, '回收站暂时无法读取。')
}
loading.value = false
}
const restore = async (item) => {
const key = `${item.resourceType}-${item.resourceId}`
if (restoringKey.value) return
restoringKey.value = key
try { await genealogyCapabilityApi.restoreRecycleItem(genealogyId.value, item.resourceType, item.resourceId); await load() }
catch (requestError) { recycleError.value = getRequestErrorMessage(requestError, '恢复失败,请稍后重试。') }
finally { restoringKey.value = '' }
}
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ''); void load() })
onUnload(() => controller.abort())
</script>
<style lang="scss" scoped>
.capability-page { min-height: 100vh; color: $ink; }
.page-header { position: relative; z-index: 1; }
.page-content { position: relative; z-index: 1; padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom)); }
.tab-row { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
.tab { flex: 1; min-height: 80rpx; border: 1rpx solid rgba($gold, .38); border-radius: 12rpx; background: rgba(255,252,245,.88); color: $ink-muted; font-size: 26rpx; }
.tab.active { border-color: $brand-red; background: $brand-red; color: #fff; }
.panel,.state-card { padding: 28rpx; border: 1rpx solid rgba($gold, .38); border-radius: 16rpx; background: rgba(255,252,245,.92); }
.panel-title,.panel-copy,.row text,.empty-copy { display: block; }
.panel-title { font-size: 32rpx; font-weight: 700; }.panel-copy,.row-meta,.empty-copy { margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; }
.row { display:flex; justify-content:space-between; align-items:center; gap:16rpx; padding:24rpx 0; border-top:1rpx solid rgba($gold, .38); }.row:first-of-type { margin-top:20rpx; }.row--recycle > view { flex:1; min-width:0; }
.restore-button { flex: 0 0 176rpx; width: 176rpx; }
</style>
+83 -20
View File
@@ -8,7 +8,7 @@
<text class="create-card__eyebrow">立谱信息</text>
<text class="create-card__title">为家族创建一部家谱</text>
<text class="create-card__note"
>创建成功后会回到我的家谱可在世系树中录入首位成员</text
>创建时会同步建立始迁祖人物创建成功后会回到我的家谱</text
>
<view class="field-row">
@@ -84,6 +84,37 @@
/>
</view>
<view class="field-row">
<text class="field-row__label"
><text class="required-mark">*</text>始迁祖</text
>
<input
v-model="form.firstAncestorName"
maxlength="30"
placeholder="请输入始迁祖姓名"
placeholder-class="placeholder"
@input="clearFieldError('firstAncestorName')"
/>
</view>
<text v-if="fieldErrors.firstAncestorName" class="field-error">{{
fieldErrors.firstAncestorName
}}</text>
<view class="owner-ancestor-field">
<view>
<text class="owner-ancestor-field__label">谱主本人就是始迁祖</text>
<text class="owner-ancestor-field__hint"
>仅本人确为始迁祖时开启开启后账号会绑定到该人物</text
>
</view>
<switch
:checked="form.ownerIsFirstAncestor"
color="#9f170f"
aria-label="谱主本人就是始迁祖"
@change="form.ownerIsFirstAncestor = $event.detail.value"
/>
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input
@@ -208,7 +239,6 @@ import {
import { genealogyApi } from "@/services/api/genealogy-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 {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
@@ -229,6 +259,8 @@ const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
firstAncestorName: "",
ownerIsFirstAncestor: false,
originPlace: "",
addressDetail: "",
intro: "",
@@ -237,6 +269,7 @@ const form = reactive({
const fieldErrors = reactive({
surname: "",
genealogyName: "",
firstAncestorName: "",
regionCode: "",
});
const submitError = ref("");
@@ -248,7 +281,11 @@ const coverOssId = ref(null);
const coverFileName = ref("");
const coverUploadRequestController = createRequestController();
const genealogyCreateRequestController = createRequestController();
const genealogyCreateGuard = createNonIdempotentWriteGuard();
const createGenealogyRequestId = () =>
typeof globalThis.crypto?.randomUUID === "function"
? `app-genealogy-${globalThis.crypto.randomUUID()}`
: `app-genealogy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const genealogyCreateRequestId = ref(createGenealogyRequestId());
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
@@ -262,6 +299,8 @@ const hasDraft = computed(
Object.entries(form).some(([key, value]) =>
key === "accessPreset"
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
: key === "ownerIsFirstAncestor"
? value === true
: String(value).trim(),
) ||
Boolean(selectedRegion.value?.regionCode) ||
@@ -282,12 +321,16 @@ const clearFieldError = (field) => {
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.firstAncestorName = form.firstAncestorName.trim()
? ""
: "请填写始迁祖姓名";
fieldErrors.regionCode = selectedRegion.value?.regionCode
? ""
: "请选择所在地区";
return (
!fieldErrors.surname &&
!fieldErrors.genealogyName &&
!fieldErrors.firstAncestorName &&
!fieldErrors.regionCode
);
};
@@ -354,7 +397,6 @@ const submitCreate = async () => {
if (!createdGenealogyId.value && !validate()) return;
let createPayload = null;
let createAttempt = null;
if (!createdGenealogyId.value) {
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
@@ -366,18 +408,15 @@ const submitCreate = async () => {
genealogyName: form.genealogyName,
regionCode: selectedRegion.value.regionCode,
ancestralHall: form.ancestralHall,
firstAncestorName: form.firstAncestorName,
ownerIsFirstAncestor: form.ownerIsFirstAncestor,
requestId: genealogyCreateRequestId.value,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
coverOssId: coverOssId.value,
...access,
};
createAttempt = genealogyCreateGuard.begin(createPayload);
if (createAttempt === null) {
submitError.value =
"上次创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
return;
}
}
isSubmitting.value = true;
@@ -402,15 +441,6 @@ const submitCreate = async () => {
);
} catch (error) {
if (!pageActive) return;
if (
!createdGenealogyId.value &&
createAttempt &&
genealogyCreateGuard.recordFailure(createAttempt, error)
) {
submitError.value =
"创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
return;
}
if (isRequestCancelled(error)) return;
submitError.value = createdGenealogyId.value
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
@@ -439,7 +469,7 @@ onUnload(() => {
}
.page-content {
z-index: 1;
padding: 24rpx 32rpx 72rpx;
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
}
.create-card {
@include adaptive-genealogy-state-panel;
@@ -500,6 +530,7 @@ onUnload(() => {
}
.field-row input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
@@ -552,6 +583,38 @@ onUnload(() => {
font-size: clamp(16px, 27rpx, 20px);
line-height: 1.55;
}
.owner-ancestor-field {
display: flex;
min-height: 112rpx;
align-items: center;
justify-content: space-between;
gap: 24rpx;
margin-top: 22rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.owner-ancestor-field > view {
min-width: 0;
flex: 1;
}
.owner-ancestor-field__label,
.owner-ancestor-field__hint {
display: block;
}
.owner-ancestor-field__label {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.owner-ancestor-field__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
.cover-field {
display: grid;
gap: 12rpx;
+11 -3
View File
@@ -294,7 +294,7 @@ const applyRows = (rows) => {
genealogyName.value = named?.genealogyName || "当前家谱";
};
const loadPoems = async ({ management = false } = {}) => {
if (!genealogyId.value) {
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
poemState.value = "error";
return false;
}
@@ -473,7 +473,7 @@ onUnload(() => {
@include adaptive-genealogy-state-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto 0;
margin: 18rpx auto calc(34rpx + env(safe-area-inset-bottom));
padding: 76rpx 8%;
box-sizing: border-box;
}
@@ -526,7 +526,7 @@ onUnload(() => {
.poem-row {
@include adaptive-genealogy-form-field;
display: grid;
min-height: 72rpx;
min-height: var(--app-touch-min);
margin-top: 10rpx;
grid-template-columns: minmax(116rpx, 38%) minmax(0, 1fr) auto;
align-items: center;
@@ -726,4 +726,12 @@ onUnload(() => {
width: calc(100% - 48rpx);
}
}
@media (max-width: 340px) {
.poem-editor__actions {
flex-direction: column;
}
.poem-editor__actions .poem-action {
width: 100%;
}
}
</style>
+12 -3
View File
@@ -135,9 +135,18 @@ const confirmation = createDiscardConfirmation((visible) => {
const confirmDiscard = confirmation.confirm;
const cancelDiscard = confirmation.cancel;
const decodeQueryText = (value) => {
const text = String(value || "");
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
genealogyName.value = String(query?.genealogyName || "");
genealogyName.value = decodeQueryText(query?.genealogyName);
});
const backToSearch = () => returnTo("G06");
@@ -204,7 +213,7 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.join-form {
@@ -277,7 +286,7 @@ onUnload(() => {
line-height: 1.5;
}
.form-field input {
min-height: 76rpx;
min-height: var(--app-touch-min);
padding-top: 0;
padding-bottom: 0;
}
+120 -4
View File
@@ -89,6 +89,13 @@
label="移出家谱"
@click="openConfirmation('remove', member)"
/>
<AppButton
v-if="isOwnerViewer && member.roleType === GENEALOGY_MEMBER_ROLE.ADMIN"
compact
type="secondary"
label="权限设置"
@click="openPermissionEditor(member)"
/>
<AppButton
v-if="member.capabilities.canLeave"
compact
@@ -159,6 +166,18 @@
>
<text v-if="operationError" class="edit-error" role="alert">{{ operationError }}</text>
</AppDialog>
<AppDialog :visible="Boolean(permissionTarget)" eyebrow="管理员权限" :title="`设置${permissionTarget?.memberName || '成员'}的权限`" :confirm-text="permissionSaving ? '正在保存' : '保存权限'" cancel-text="取消" show-cancel :close-on-mask="!permissionSaving" @confirm="savePermissions" @cancel="closePermissionEditor">
<AppLoading v-if="permissionLoading" text="正在读取权限目录" />
<view v-else-if="permissionOptions.length" class="permission-list">
<label v-for="option in permissionOptions" :key="option.code" class="permission-item" :class="{ 'permission-item--disabled': !option.enabled }">
<checkbox :checked="selectedPermissionCodes.includes(option.code)" :disabled="!option.enabled || permissionSaving" @click="togglePermission(option)" />
<view><text>{{ option.label }}</text><text v-if="option.description">{{ option.description }}</text></view>
</label>
</view>
<text v-else-if="!permissionError" class="edit-hint">当前没有可授权的权限项</text>
<textarea v-model.trim="permissionReason" maxlength="200" placeholder="授权原因(可选)" class="permission-reason" />
<text v-if="permissionError" class="edit-error">{{ permissionError }}</text>
</AppDialog>
</view>
</template>
@@ -179,6 +198,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
@@ -198,6 +218,17 @@ const hasLeftGenealogy = ref(false);
const personOptions = ref([]);
const personOptionsState = ref("idle");
const memberListController = createRequestController();
const permissionTarget = ref(null);
const selectedPermissionCodes = ref([]);
const permissionReason = ref("");
const permissionSaving = ref(false);
const permissionLoading = ref(false);
const permissionError = ref("");
const permissionOptions = ref([]);
const permissionCatalogController = createRequestController();
const memberPermissionController = createRequestController();
const memberPermissionSaveController = createRequestController();
let permissionLoadSequence = 0;
const personOptionsController = createRequestController();
const memberUpdateController = createRequestController();
const membershipOperationController = createRequestController();
@@ -470,12 +501,86 @@ const returnToOverview = () =>
hasLeftGenealogy.value
? returnTo("G01")
: returnTo("G05", { genealogyId: genealogyId.value });
const openPermissionEditor = async (member) => {
const activeLoad = ++permissionLoadSequence;
permissionCatalogController.abort();
memberPermissionController.abort();
permissionTarget.value = member;
permissionOptions.value = [];
selectedPermissionCodes.value = [];
permissionReason.value = "";
permissionError.value = "";
permissionLoading.value = true;
try {
const catalog = await genealogyCapabilityApi.getPermissionCatalog(
genealogyId.value,
{ requestController: permissionCatalogController },
);
const enabledCodes = catalog.filter((option) => option.enabled).map((option) => option.code);
const permissions = await genealogyCapabilityApi.getMemberPermissions(
genealogyId.value,
member.memberId,
enabledCodes,
{ requestController: memberPermissionController },
);
if (!isPageActive || activeLoad !== permissionLoadSequence) return;
permissionOptions.value = catalog;
selectedPermissionCodes.value = permissions.permissionCodes;
} catch (error) {
if (!isPageActive || activeLoad !== permissionLoadSequence || isRequestCancelled(error)) return;
permissionError.value = getRequestErrorMessage(error, "权限目录暂时无法读取,当前不会保存任何变更。");
} finally {
if (isPageActive && activeLoad === permissionLoadSequence) permissionLoading.value = false;
}
};
const closePermissionEditor = () => {
if (permissionSaving.value) return;
permissionLoadSequence += 1;
permissionCatalogController.abort();
memberPermissionController.abort();
permissionTarget.value = null;
permissionLoading.value = false;
};
const togglePermission = (option) => {
if (!option?.enabled || permissionSaving.value || permissionLoading.value) return;
selectedPermissionCodes.value = selectedPermissionCodes.value.includes(option.code)
? selectedPermissionCodes.value.filter((code) => code !== option.code)
: [...selectedPermissionCodes.value, option.code];
};
const savePermissions = async () => {
if (!permissionTarget.value || permissionSaving.value || permissionLoading.value || permissionError.value) return;
const enabledCodes = permissionOptions.value.filter((option) => option.enabled).map((option) => option.code);
permissionSaving.value = true;
permissionError.value = "";
try {
await genealogyCapabilityApi.saveMemberPermissions(
genealogyId.value,
permissionTarget.value.memberId,
selectedPermissionCodes.value,
permissionReason.value,
enabledCodes,
{ requestController: memberPermissionSaveController },
);
if (!isPageActive) return;
feedbackMessage.value = "管理员权限已保存";
permissionTarget.value = null;
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
permissionError.value = getRequestErrorMessage(error, "权限保存失败,请稍后重试。");
} finally {
if (isPageActive) permissionSaving.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(editTarget.value || confirmTarget.value),
submitting: operationPending.value,
transientOpen: Boolean(editTarget.value || confirmTarget.value || permissionTarget.value),
submitting: operationPending.value || permissionSaving.value,
"close-transient": () =>
editTarget.value ? closeEdit() : closeConfirmation(),
editTarget.value
? closeEdit()
: confirmTarget.value
? closeConfirmation()
: closePermissionEditor(),
"block-submitting": () => true,
});
@@ -500,6 +605,9 @@ onUnload(() => {
personOptionsController.abort();
memberUpdateController.abort();
membershipOperationController.abort();
permissionCatalogController.abort();
memberPermissionController.abort();
memberPermissionSaveController.abort();
});
</script>
@@ -518,7 +626,7 @@ onUnload(() => {
}
.page-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
@@ -725,4 +833,12 @@ onUnload(() => {
.edit-error {
color: $brand-red;
}
.permission-list { margin-top: 18rpx; }
.permission-item { display: flex; min-height: 76rpx; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid rgba($gold, 0.38); color: $ink; font-size: 26rpx; }
.permission-item checkbox { margin-right: 16rpx; }
.permission-item view { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 6rpx; }
.permission-item view text:last-child:not(:first-child) { color: $ink-muted; font-size: 22rpx; line-height: 1.45; }
.permission-item--disabled { opacity: 0.54; }
.permission-reason { width: 100%; min-height: 100rpx; margin-top: 18rpx; padding: 16rpx; box-sizing: border-box; border: 1rpx solid rgba($gold, 0.38); border-radius: 10rpx; }
</style>
+2 -2
View File
@@ -239,7 +239,7 @@ onUnload(() => {
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.applications-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.page-content { padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom)); }
.state-card, .application-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.state-card text, .application-card__relation, .application-card__remark, .page-feedback { display: block; }
@@ -251,7 +251,7 @@ onUnload(() => {
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
.application-card { padding: 28rpx 30rpx; }
.application-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
.application-card__heading text:first-child { min-width: 0; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
.application-card__heading text:first-child { min-width: 0; flex: 1; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
.application-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
.application-card__relation { margin-top: 14rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
.application-card__status { display: block; margin-top: 16rpx; color: $brand-red; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
+270 -4
View File
@@ -28,7 +28,7 @@
mode="aspectFit"
/>
<text class="state-title">暂时无法读取家谱</text>
<text class="state-copy">网络或服务暂不可用请稍后重新查看</text>
<text class="state-copy">{{ genealogyListError }}</text>
<image
class="error-panel__divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
@@ -171,6 +171,64 @@
/>
</view>
<view class="featured-media-section">
<view class="featured-media-heading">
<view>
<text class="featured-media-heading__title">宣传视频</text>
<text class="featured-media-heading__copy">家谱文化与使用介绍</text>
</view>
<button class="featured-media-more" @click="openPlatformVideos">
查看更多
</button>
</view>
<view v-if="featuredVideoState === 'loading'" class="featured-media-state">
<AppLoading text="正在读取宣传视频" />
</view>
<view v-else-if="featuredVideoState === 'error'" class="featured-media-state">
<text>宣传视频暂时无法显示</text>
<button class="featured-media-retry" @click="loadFeaturedVideos">重新加载</button>
</view>
<view v-else-if="featuredVideos.length" class="featured-media-grid">
<view
v-for="video in featuredVideos"
:key="video.id"
class="featured-media-card"
>
<view
v-if="video.coverFile?.accessUrl"
class="featured-media-cover-button"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openFeaturedVideo(video)"
>
<image
class="featured-media-cover"
:src="video.coverFile.accessUrl"
mode="aspectFill"
/>
<view class="featured-media-play" aria-hidden="true"></view>
</view>
<video
v-else
class="featured-media-video"
:src="video.videoFile.accessUrl"
controls
object-fit="cover"
/>
<button class="featured-media-title" @click="openFeaturedVideo(video)">
{{ video.title }}
</button>
</view>
</view>
<view v-else class="featured-media-state featured-media-state--empty">
<text>暂时没有推荐视频</text>
<button class="featured-media-more featured-media-more--empty" @click="openPlatformVideos">
查看全部视频
</button>
</view>
</view>
<view class="create-action" @click="openAddDialog">
<image
class="create-cloud"
@@ -226,6 +284,12 @@
>
<text class="empty-create-action__copy">创建家谱</text>
</view>
<AppButton
block
type="secondary"
label="观看宣传视频"
@click="openPlatformVideos"
/>
<text class="empty-create-note"
>确认没有现有家谱后再创建避免重复建谱</text
>
@@ -279,7 +343,10 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import {
@@ -291,6 +358,7 @@ import {
const isLoading = ref(false);
const hasError = ref(false);
const genealogyListError = ref("");
const genealogies = ref([]);
const contextInvalidated = ref(false);
const contextReconcileFailed = ref(false);
@@ -304,14 +372,18 @@ const listScrollCommand = ref(0);
const currentListScrollTop = ref(0);
const unreadCount = ref(0);
const creationQuota = ref(null);
const featuredVideos = ref([]);
const featuredVideoState = ref("loading");
const genealogyListRequestController = createRequestController();
const unreadRequestController = createRequestController();
const quotaRequestController = createRequestController();
const featuredVideoRequestController = createRequestController();
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
let genealogyLoadGeneration = 0;
let unreadLoadGeneration = 0;
let quotaLoadGeneration = 0;
let featuredVideoLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
@@ -340,6 +412,7 @@ const reconcilePageGenealogyContext = () => {
selectedGenealogyId.value = null;
contextReconcileFailed.value = true;
contextInvalidated.value = false;
genealogyListError.value = "本机家谱选择状态异常,请重新加载。";
hasError.value = true;
return false;
}
@@ -350,6 +423,7 @@ const loadGenealogies = async () => {
genealogyListRequestController.abort();
isLoading.value = true;
hasError.value = false;
genealogyListError.value = "";
try {
const loadedGenealogies = await genealogyApi.getMyGenealogies({
requestController: genealogyListRequestController,
@@ -365,6 +439,10 @@ const loadGenealogies = async () => {
) {
return;
}
genealogyListError.value =
error?.code === "GENEALOGY_RESPONSE_INVALID"
? "服务返回的家谱数据不完整,请联系管理员处理。"
: getRequestErrorMessage(error, "家谱服务暂时无法读取,请稍后重试。");
hasError.value = true;
} finally {
if (pageActive && generation === genealogyLoadGeneration) {
@@ -407,11 +485,37 @@ const loadQuota = async () => {
}
};
const loadFeaturedVideos = async () => {
const generation = ++featuredVideoLoadGeneration;
featuredVideoRequestController.abort();
featuredVideoState.value = "loading";
try {
const rows = await genealogyCapabilityApi.getPlatformVideos(
PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
{ requestController: featuredVideoRequestController },
);
if (!pageActive || generation !== featuredVideoLoadGeneration) return;
featuredVideos.value = rows.slice(0, 2);
featuredVideoState.value = "ready";
} catch (error) {
if (
!pageActive ||
generation !== featuredVideoLoadGeneration ||
isRequestCancelled(error)
) {
return;
}
featuredVideos.value = [];
featuredVideoState.value = "error";
}
};
onLoad((query) => {
requestedGenealogyId.value = String(query?.genealogyId || "");
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onShow(() => {
@@ -423,6 +527,7 @@ onShow(() => {
) {
requestedGenealogyId.value = navigationResult.entityId;
loadGenealogies();
loadFeaturedVideos();
return;
}
if (skipNextShowRefresh) {
@@ -432,6 +537,7 @@ onShow(() => {
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onUnload(() => {
@@ -439,9 +545,11 @@ onUnload(() => {
genealogyLoadGeneration += 1;
unreadLoadGeneration += 1;
quotaLoadGeneration += 1;
featuredVideoLoadGeneration += 1;
genealogyListRequestController.abort();
unreadRequestController.abort();
quotaRequestController.abort();
featuredVideoRequestController.abort();
});
const hasGenealogies = computed(() => genealogies.value.length > 0);
@@ -536,6 +644,21 @@ const openSwitcher = () => {
const closeSwitcher = () => {
switcherVisible.value = false;
};
const openPlatformVideos = () =>
openPage(
"F11",
{ placement: PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER },
"G01",
);
const openFeaturedVideo = (video) =>
openPage(
"F11",
{
placement: PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
videoId: String(video.id),
},
"G01",
);
const openOrderDialog = () => {
if (genealogies.value.length < 2) return;
orderDialogVisible.value = true;
@@ -596,7 +719,7 @@ const openShortcut = (shortcutKey) => {
const genealogyId = String(currentGenealogy.value.id);
const actions = {
tree: () => openPage("T01", { genealogyId }, "G01"),
members: () => openPage("G05", { genealogyId }, "G01"),
members: () => openPage("G13", { genealogyId }, "G01"),
poem: () => openPage("G12", { genealogyId }, "G01"),
applications: () => openPage("G10", { genealogyId }, "G01"),
};
@@ -617,7 +740,7 @@ const openShortcut = (shortcutKey) => {
.genealogy-content {
z-index: 1;
padding: 24rpx 32rpx 176rpx;
padding: 24rpx 32rpx calc(176rpx + env(safe-area-inset-bottom));
}
.genealogy-index--split {
@@ -855,6 +978,149 @@ const openShortcut = (shortcutKey) => {
flex: 0 0 auto;
}
.featured-media-section {
margin: 0 0 24rpx;
padding: 26rpx 24rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 250, 240, 0.72);
}
.featured-media-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.featured-media-heading__title,
.featured-media-heading__copy {
display: block;
}
.featured-media-heading__title {
color: #5c4330;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.featured-media-heading__copy {
margin-top: 8rpx;
color: #8a7564;
font-size: clamp(14px, 23rpx, 17px);
}
.featured-media-more,
.featured-media-retry,
.featured-media-title {
min-height: var(--app-touch-min);
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: 1.4;
}
.featured-media-more::after,
.featured-media-retry::after,
.featured-media-title::after {
border: 0;
}
.featured-media-more,
.featured-media-retry {
flex: 0 0 auto;
color: $brand-red;
font-size: clamp(13px, 22rpx, 16px);
}
.featured-media-state {
display: flex;
min-height: 176rpx;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
text-align: center;
}
.featured-media-state--empty {
min-height: 138rpx;
}
.featured-media-more--empty {
margin-top: 4rpx;
}
.featured-media-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 22rpx;
}
.featured-media-card {
min-width: 0;
}
.featured-media-cover-button,
.featured-media-video {
width: 100%;
height: 176rpx;
border-radius: 10rpx;
background: #1f1b17;
overflow: hidden;
}
.featured-media-cover-button {
position: relative;
}
.featured-media-cover {
width: 100%;
height: 100%;
}
.featured-media-play {
position: absolute;
top: 50%;
left: 50%;
display: flex;
width: 58rpx;
height: 58rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: rgba(45, 29, 19, 0.62);
transform: translate(-50%, -50%);
}
.featured-media-play::after {
width: 0;
height: 0;
margin-left: 5rpx;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 16rpx solid #fff;
content: "";
}
.featured-media-title {
display: block;
width: 100%;
margin-top: 8rpx;
color: $ink;
font-size: clamp(14px, 24rpx, 17px);
font-weight: 600;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.create-action {
display: flex;
min-height: 96rpx;
@@ -1076,7 +1342,7 @@ const openShortcut = (shortcutKey) => {
display: flex;
align-items: center;
justify-content: center;
min-height: 76rpx;
min-height: var(--app-touch-min);
margin-top: 14rpx;
color: $brand-red;
}
+28 -1
View File
@@ -160,6 +160,30 @@
mode="aspectFit"
/>
</view>
<view class="overview-action" @click="toPersonDocuments">
<image
class="overview-action__icon"
src="/static/assets/modules/genealogy/transparent/shortcut-members.png"
mode="aspectFit"
/>
<view class="overview-action__copy">
<text class="overview-action__title">重要证件</text>
<text>集中查看家谱证件档案</text>
</view>
<image
class="overview-action__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view v-if="canManageGenealogy" class="overview-action" @click="toCapabilityCenter">
<image class="overview-action__icon" src="/static/assets/modules/genealogy/transparent/settings-gear.png" mode="aspectFit" />
<view class="overview-action__copy">
<text class="overview-action__title">资料与回收站</text>
<text>查看资料完整度恢复误删的家谱内容</text>
</view>
<image class="overview-action__chevron" src="/static/assets/foundation/transparent/chevron-right.png" mode="aspectFit" />
</view>
<template v-else>
<view class="overview-summary">
<text class="overview-action__title">成员身份</text>
@@ -308,7 +332,7 @@ const loadGenealogy = async (query = {}) => {
genealogyId.value = String(query.genealogyId || genealogyId.value || "");
canManageGenealogy.value = false;
if (!genealogyId.value) {
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
overviewState.value = "empty";
return;
}
@@ -363,10 +387,13 @@ const toGenerationPoems = () =>
openPage("G12", { genealogyId: genealogyId.value }, "G05");
const toMembers = () =>
openPage("G13", { genealogyId: genealogyId.value }, "G05");
const toPersonDocuments = () =>
openPage("R12", { genealogyId: genealogyId.value }, "G05");
const openInvitationManager = () => {
if (overviewState.value !== "ready" || !genealogyId.value) return;
invitationManager.value?.open();
};
const toCapabilityCenter = () => openPage("G14", { genealogyId: genealogyId.value }, "G05");
const requestBack = () => {
if (invitationBusy.value || invitationTransientOpen.value) {
invitationManager.value?.closeTransient();
+152 -15
View File
@@ -2,7 +2,7 @@
<view class="search-page">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
><PageHeader title="搜索家谱" custom-back @back="requestBack"
/></view>
<view class="page-content">
<view class="invite-card">
@@ -45,8 +45,41 @@
</view>
<view class="search-note"
><text>公开家谱</text
><text>以下是可加入的公开家谱</text></view
><text>输入谱名或姓氏查找可以申请加入的公开家谱</text></view
>
<view class="public-search-card">
<text class="public-search-card__label">查找公开家谱</text>
<view class="public-search-card__control">
<input
v-model="searchKeyword"
maxlength="50"
confirm-type="search"
placeholder="请输入谱名或姓氏"
aria-label="公开家谱搜索关键词"
@confirm="searchGenealogies"
/>
<button
v-if="searchKeyword"
class="public-search-card__clear"
aria-label="清除家谱搜索关键词"
@click="clearSearchKeyword"
>
清除
</button>
</view>
<text
v-if="genealogySearchState === 'ready'"
class="public-search-card__result"
role="status"
>{{ searchResultCopy }}</text>
<AppButton
block
type="secondary"
:disabled="genealogySearchState === 'loading'"
:label="genealogySearchState === 'loading' ? '正在搜索' : '搜索公开家谱'"
@click="searchGenealogies"
/>
</view>
<view v-if="genealogySearchState === 'loading'" class="state-card"
><AppLoading
text="正在读取公开家谱"
@@ -64,8 +97,13 @@
<view v-else-if="genealogySearchState === 'empty'" class="state-card"
><text>暂未找到公开家谱</text></view
>
<view v-else-if="!filteredRows.length" class="state-card">
<text>没有找到匹配的公开家谱</text>
<text class="state-card__copy">可尝试缩短关键词或改用姓氏查找</text>
<AppButton block type="secondary" label="清除关键词" @click="clearSearchKeyword" />
</view>
<view v-else class="result-list">
<view v-for="item in rows" :key="item.id" class="genealogy-card">
<view v-for="item in filteredRows" :key="item.id" class="genealogy-card">
<view class="card-heading"
><text>{{ item.name }}</text
><text v-if="item.surname">{{ item.surname }}</text></view
@@ -77,8 +115,8 @@
<view class="card-footer"
><text>{{ item.memberCount }} 位成员</text
><AppButton
:label="item.canManage ? '已在我的家谱' : '申请加入'"
:disabled="item.canManage"
:label="item.hasMembership ? '已在我的家谱' : '申请加入'"
:disabled="item.hasMembership"
@click="applyToJoin(item)"
/></view>
</view>
@@ -101,7 +139,7 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -115,10 +153,12 @@ import { genealogyMembershipApi } from "@/services/api/genealogy-membership-serv
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const rows = ref([]);
const genealogySearchState = ref("loading");
const searchKeyword = ref("");
const loadedSearchKeyword = ref("");
const inviteToken = ref("");
const inviteState = ref("idle");
const inviteError = ref("");
@@ -130,15 +170,40 @@ const invitationPreviewController = createRequestController();
const invitationRedemptionController = createRequestController();
const invitationRedemptionGuard = createNonIdempotentWriteGuard();
let isPageActive = true;
const loadGenealogies = async () => {
const normalizedSearchKeyword = computed(() =>
searchKeyword.value.trim().toLocaleLowerCase(),
);
const filteredRows = computed(() => {
if (!normalizedSearchKeyword.value) return rows.value;
return rows.value.filter((genealogy) =>
[genealogy.name, genealogy.surname].some((fieldValue) =>
String(fieldValue || "")
.toLocaleLowerCase()
.includes(normalizedSearchKeyword.value),
),
);
});
const searchResultCopy = computed(() =>
normalizedSearchKeyword.value
? `找到 ${filteredRows.value.length} 部匹配家谱`
: `${rows.value.length} 部公开家谱`,
);
const clearSearchKeyword = () => {
searchKeyword.value = "";
if (loadedSearchKeyword.value) void loadGenealogies("");
};
const loadGenealogies = async (keyword = searchKeyword.value) => {
publicGenealogyListController.abort();
genealogySearchState.value = "loading";
try {
const publicGenealogies = await genealogyApi.getPublicGenealogies({
requestController: publicGenealogyListController,
});
const normalizedKeyword = String(keyword || "").trim();
const publicGenealogies = await genealogyApi.getPublicGenealogies(
{ keyword: normalizedKeyword },
{ requestController: publicGenealogyListController },
);
if (!isPageActive) return;
rows.value = publicGenealogies;
loadedSearchKeyword.value = normalizedKeyword;
genealogySearchState.value = rows.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
@@ -196,6 +261,7 @@ const previewInvite = async () => {
inviteError.value = getRequestErrorMessage(error, "邀请码暂时无法查看,请检查后重试。");
}
};
const searchGenealogies = () => loadGenealogies(searchKeyword.value);
const openRedeemConfirmation = () => {
if (inviteState.value === "ready" && invitePreview.value)
redeemConfirmationVisible.value = true;
@@ -203,6 +269,14 @@ const openRedeemConfirmation = () => {
const closeRedeemConfirmation = () => {
if (inviteState.value !== "redeeming") redeemConfirmationVisible.value = false;
};
const requestBack = () => {
if (inviteState.value === "redeeming") return true;
if (redeemConfirmationVisible.value) {
closeRedeemConfirmation();
return true;
}
return backToGenealogies();
};
const redeemInvite = async () => {
if (inviteState.value !== "ready" || !invitePreview.value) return;
const redemptionPayload = { token: inviteToken.value };
@@ -242,9 +316,9 @@ const handleInviteResult = () =>
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
? returnTo("G01")
: openPage("G09", {}, "G06");
onLoad(loadGenealogies);
onLoad(() => loadGenealogies(""));
onShow(() => {
if (genealogySearchState.value !== "loading") loadGenealogies();
if (genealogySearchState.value !== "loading") loadGenealogies(searchKeyword.value);
});
onUnload(() => {
isPageActive = false;
@@ -252,6 +326,7 @@ onUnload(() => {
invitationPreviewController.abort();
invitationRedemptionController.abort();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -267,9 +342,10 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.search-note,
.public-search-card,
.state-card,
.genealogy-card,
.invite-card {
@@ -303,7 +379,7 @@ onUnload(() => {
@include adaptive-genealogy-form-field;
display: block;
width: 100%;
min-height: 78rpx;
min-height: var(--app-touch-min);
margin-top: 18rpx;
padding: 0 22rpx;
box-sizing: border-box;
@@ -349,6 +425,58 @@ onUnload(() => {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.public-search-card {
margin-top: 18rpx;
padding: 24rpx 30rpx;
}
.public-search-card__label,
.public-search-card__result {
display: block;
}
.public-search-card__label {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.public-search-card__control {
@include adaptive-genealogy-form-field;
display: flex;
min-height: var(--app-touch-min);
align-items: center;
margin-top: 12rpx;
padding: 0 10rpx 0 20rpx;
box-sizing: border-box;
}
.public-search-card__control input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.public-search-card__clear {
min-width: 96rpx;
min-height: calc(var(--app-touch-min) - 16rpx);
margin: 0;
padding: 0 14rpx;
border: 0;
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
line-height: calc(var(--app-touch-min) - 16rpx);
}
.public-search-card__clear::after {
border: 0;
}
.public-search-card__result {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.public-search-card > .app-button {
margin-top: 16rpx;
}
.state-card {
display: flex;
min-height: 310rpx;
@@ -361,6 +489,13 @@ onUnload(() => {
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
}
.state-card__copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.state-card .app-button {
width: 100%;
margin-top: 22rpx;
@@ -381,6 +516,8 @@ onUnload(() => {
gap: 18rpx;
}
.card-heading text:first-child {
min-width: 0;
flex: 1;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 31rpx, 22px);
+283 -5
View File
@@ -2,7 +2,7 @@
<view class="settings-page" :class="`settings-state--${pageState}`">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="家谱设置" custom-back @back="returnToOverview"
><PageHeader title="家谱设置" custom-back @back="requestBack"
/></view>
<view class="page-content">
@@ -142,6 +142,7 @@
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="isUploading || isSubmitting" @click="clearCover">移除封面</button>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
@@ -186,6 +187,24 @@
@click="requestLifecycleChange"
/>
</view>
<view
v-if="canDeletePermanently || permanentDeletionDisabledReason"
class="permanent-deletion"
>
<text>永久删除家谱</text>
<text>{{
canDeletePermanently
? "该操作会永久删除家谱及其业务内容,无法从回收站恢复。"
: permanentDeletionDisabledReason
}}</text>
<AppButton
block
type="secondary"
:disabled="!canDeletePermanently || permanentDeletionSubmitting"
label="永久删除家谱"
@click="openPermanentDeletion"
/>
</view>
</view>
<view v-else class="state-card">
@@ -206,6 +225,54 @@
@confirm="confirmLifecycleChange"
@cancel="lifecycleDialogVisible = false"
/>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃家谱设置修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="permanentDeletionVisible"
eyebrow="高风险操作"
title="永久删除这部家谱?"
:message="`请输入完整谱名“${form.genealogyName}”,并通过账号短信验证。删除后无法恢复。`"
:confirm-text="permanentDeletionSubmitting ? '正在删除' : '确认永久删除'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmPermanentDeletion"
@cancel="closePermanentDeletion"
>
<input
v-model="permanentDeletionConfirmationName"
class="deletion-dialog-input"
maxlength="24"
:placeholder="`输入谱名:${form.genealogyName}`"
/>
<view class="deletion-code-row">
<input
v-model.trim="permanentDeletionSmsCode"
class="deletion-dialog-input"
type="number"
maxlength="4"
:placeholder="`验证码将发送至 ${deletionVerificationPhoneMasked}`"
/>
<AppButton
compact
type="secondary"
:disabled="permanentDeletionSubmitting || deletionCodeSending || deletionCodeCooldown > 0"
:label="deletionCodeButtonLabel"
@click="sendPermanentDeletionCode"
/>
</view>
<text v-if="permanentDeletionError" class="submit-error">{{ permanentDeletionError }}</text>
</AppDialog>
<RegionPickerDialog
ref="regionPickerDialog"
@@ -213,13 +280,14 @@
@select="selectRegion"
@loading-change="regionLoading = $event"
@error-change="regionPickerError = $event"
@transient-change="regionPickerVisible = $event"
/>
</view>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
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";
@@ -245,7 +313,10 @@ import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const pageState = ref("loading");
@@ -272,8 +343,18 @@ const coverFileName = ref("");
const lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
const canArchive = ref(false);
const canRestore = ref(false);
const canDeletePermanently = ref(false);
const permanentDeletionDisabledReason = ref("");
const deletionVerificationPhoneMasked = ref("");
const lifecycleDialogVisible = ref(false);
const lifecycleSubmitting = ref(false);
const permanentDeletionVisible = ref(false);
const permanentDeletionConfirmationName = ref("");
const permanentDeletionSmsCode = ref("");
const permanentDeletionError = ref("");
const permanentDeletionSubmitting = ref(false);
const deletionCodeSending = ref(false);
const deletionCodeCooldown = ref(0);
const currentRegionCode = ref("");
const currentRegionDisplay = ref("");
const selectedRegion = ref(null);
@@ -281,13 +362,28 @@ const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerVisible = ref(false);
const settingsBaseline = ref("");
const discardVisible = ref(false);
const settingsReadRequestController = createRequestController();
const deletionCapabilityReadRequestController = createRequestController();
const genealogyLifecycleRequestController = createRequestController();
const deletionCodeRequestController = createRequestController();
const permanentDeletionRequestController = createRequestController();
const coverUploadRequestController = createRequestController();
const settingsSaveRequestController = createRequestController();
// 封面上传、归档和设置保存属于独立操作。分别持有取消槽,避免后发操作
// 取消前一条仍在收敛的请求,留下无法解释的加载状态。
let pageActive = true;
let deletionCodeTimer = null;
const permanentDeletionGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const regionDisplay = computed(() =>
@@ -295,6 +391,23 @@ const regionDisplay = computed(() =>
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
: currentRegionDisplay.value,
);
const settingsSnapshot = computed(() => JSON.stringify({
...form,
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
coverOssId: coverOssId.value || null,
}));
const isDirty = computed(() =>
pageState.value === "form" &&
Boolean(settingsBaseline.value) &&
settingsSnapshot.value !== settingsBaseline.value,
);
const deletionCodeButtonLabel = computed(() =>
deletionCodeSending.value
? "正在发送"
: deletionCodeCooldown.value > 0
? `${deletionCodeCooldown.value}s 后重发`
: "发送验证码",
);
const stateCopy = computed(() => {
if (pageState.value === "success") {
return {
@@ -379,6 +492,22 @@ const loadSettings = async () => {
canArchive.value = settings.canArchive;
canRestore.value = settings.canRestore;
pageState.value = "form";
settingsBaseline.value = settingsSnapshot.value;
try {
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
genealogyId.value,
{ requestController: deletionCapabilityReadRequestController },
);
if (!pageActive) return;
canDeletePermanently.value = deletionCapability.canDeletePermanently;
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join("");
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
canDeletePermanently.value = false;
permanentDeletionDisabledReason.value = "永久注销资格暂时无法读取,请稍后重试。";
deletionVerificationPhoneMasked.value = "";
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageState.value = "error";
@@ -404,6 +533,13 @@ const confirmLifecycleChange = async () => {
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
canArchive.value = lifecycleResult.canArchive;
canRestore.value = lifecycleResult.canRestore;
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
genealogyId.value,
{ requestController: genealogyLifecycleRequestController },
);
canDeletePermanently.value = deletionCapability.canDeletePermanently;
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join("");
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
lifecycleDialogVisible.value = false;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -414,6 +550,92 @@ const confirmLifecycleChange = async () => {
}
};
const stopDeletionCodeCooldown = () => {
if (deletionCodeTimer) clearInterval(deletionCodeTimer);
deletionCodeTimer = null;
};
const startDeletionCodeCooldown = (seconds) => {
stopDeletionCodeCooldown();
deletionCodeCooldown.value = seconds;
deletionCodeTimer = setInterval(() => {
deletionCodeCooldown.value = Math.max(0, deletionCodeCooldown.value - 1);
if (deletionCodeCooldown.value === 0) stopDeletionCodeCooldown();
}, 1000);
};
const openPermanentDeletion = () => {
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
permanentDeletionConfirmationName.value = "";
permanentDeletionSmsCode.value = "";
permanentDeletionError.value = "";
permanentDeletionVisible.value = true;
};
const closePermanentDeletion = () => {
if (permanentDeletionSubmitting.value || deletionCodeSending.value) return;
permanentDeletionVisible.value = false;
permanentDeletionError.value = "";
};
const sendPermanentDeletionCode = async () => {
if (!canDeletePermanently.value || deletionCodeSending.value || permanentDeletionSubmitting.value || deletionCodeCooldown.value > 0) return;
deletionCodeSending.value = true;
permanentDeletionError.value = "";
try {
await genealogyApi.sendPermanentDeletionCode(
genealogyId.value,
{ requestController: deletionCodeRequestController },
);
if (!pageActive || !permanentDeletionVisible.value) return;
startDeletionCodeCooldown(60);
} catch (error) {
if (pageActive && permanentDeletionVisible.value && !isRequestCancelled(error)) {
permanentDeletionError.value = getRequestErrorMessage(error, "删除验证码发送失败,请稍后重试。");
}
} finally {
if (pageActive) deletionCodeSending.value = false;
}
};
const confirmPermanentDeletion = async () => {
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
if (permanentDeletionConfirmationName.value.trim() !== form.genealogyName.trim()) {
permanentDeletionError.value = "输入的谱名与当前家谱不一致。";
return;
}
if (!/^\d{4}$/.test(permanentDeletionSmsCode.value)) {
permanentDeletionError.value = "请输入4位短信验证码。";
return;
}
const payload = {
confirmationName: permanentDeletionConfirmationName.value,
smsCode: permanentDeletionSmsCode.value,
};
const deletionAttempt = permanentDeletionGuard.begin(payload);
if (deletionAttempt === null) {
permanentDeletionError.value = "上次删除结果待确认,请先返回家谱列表刷新,不要重复提交。";
return;
}
permanentDeletionSubmitting.value = true;
permanentDeletionError.value = "";
try {
await genealogyApi.deleteGenealogyPermanently(
genealogyId.value,
payload,
{ requestController: permanentDeletionRequestController },
);
if (!pageActive) return;
genealogyContext.invalidateCurrentGenealogyId();
permanentDeletionVisible.value = false;
await returnTo("G01");
} catch (error) {
const isOutcomeUnknown = permanentDeletionGuard.recordFailure(deletionAttempt, error);
if (!pageActive) return;
permanentDeletionError.value = isOutcomeUnknown
? "删除结果待确认,请返回家谱列表刷新,不要重复提交。"
: getRequestErrorMessage(error, "永久删除失败,请核对验证码后重试。");
if (!isOutcomeUnknown && isRequestCancelled(error)) permanentDeletionError.value = "";
} finally {
if (pageActive) permanentDeletionSubmitting.value = false;
}
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
@@ -438,6 +660,13 @@ const uploadCover = async () => {
}
};
const clearCover = () => {
if (isUploading.value || isSubmitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const submitUpdate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
@@ -458,7 +687,7 @@ const submitUpdate = async () => {
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
coverOssId: coverOssId.value,
...access,
},
{ requestController: settingsSaveRequestController },
@@ -477,6 +706,27 @@ const returnToOverview = () =>
hasValidContext.value
? returnTo("G05", { genealogyId: genealogyId.value })
: goBack();
const requestBack = async () => {
if (regionPickerVisible.value) {
regionPickerDialog.value?.close();
return true;
}
if (lifecycleDialogVisible.value) {
lifecycleDialogVisible.value = false;
return true;
}
if (permanentDeletionVisible.value) {
closePermanentDeletion();
return true;
}
if (discardVisible.value) {
cancelDiscard();
return true;
}
if (isSubmitting.value || isUploading.value || lifecycleSubmitting.value || permanentDeletionSubmitting.value || deletionCodeSending.value) return true;
if (isDirty.value && !(await requestDiscardConfirmation())) return false;
return returnToOverview();
};
const handleStateAction = () =>
pageState.value === "success" ? returnToOverview() : loadSettings();
@@ -486,12 +736,18 @@ onLoad((query) => {
onMounted(() => {
void loadSettings();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
settingsReadRequestController.abort();
deletionCapabilityReadRequestController.abort();
genealogyLifecycleRequestController.abort();
deletionCodeRequestController.abort();
permanentDeletionRequestController.abort();
coverUploadRequestController.abort();
settingsSaveRequestController.abort();
stopDeletionCodeCooldown();
discardConfirmation.dispose();
});
</script>
@@ -510,7 +766,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 32rpx 72rpx;
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
}
.settings-card,
.state-card {
@@ -567,6 +823,7 @@ onUnload(() => {
}
.field-row input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
@@ -583,6 +840,15 @@ onUnload(() => {
text-align: right;
overflow-wrap: anywhere;
}
.permanent-deletion { margin-top: 30rpx; padding: 24rpx; border: 1rpx solid rgba(158, 37, 27, .34); border-radius: 10rpx; background: rgba(158, 37, 27, .05); }
.permanent-deletion > text { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.permanent-deletion > text:first-child { color: $brand-red; font-size: clamp(16px, 27rpx, 20px); font-weight: 700; }
.permanent-deletion > text + text { margin-top: 8rpx; }
.permanent-deletion .app-button { margin-top: 18rpx; }
.deletion-dialog-input { width: 100%; min-height: var(--app-touch-min); margin-top: 14rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(158, 37, 27, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .7); }
.deletion-code-row { display: flex; align-items: center; gap: 12rpx; }
.deletion-code-row .deletion-dialog-input { min-width: 0; flex: 1; }
.deletion-code-row .app-button { width: auto; flex: 0 0 auto; margin-top: 14rpx; }
.lifecycle-note,
.lifecycle-actions {
margin-top: 20rpx;
@@ -658,6 +924,18 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.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; }
.field-error,
.submit-error {
margin-top: 10rpx;