feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user