This commit is contained in:
rain
2026-07-24 18:03:02 +08:00
parent c7f278fe79
commit ec8486b035
86 changed files with 7943 additions and 1841 deletions
+11 -1
View File
@@ -366,7 +366,7 @@ import {
isRequestCancelled,
} from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
import { consumeNavigationResult, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const isLoading = ref(false);
const hasError = ref(false);
@@ -465,6 +465,16 @@ onLoad((query) => {
});
onShow(() => {
const result = consumeNavigationResult("G01");
if (
result?.operation === "genealogy-created" &&
result.refresh &&
result.entityId
) {
requestedGenealogyId.value = result.entityId;
loadGenealogies();
return;
}
if (skipNextShowRefresh) {
skipNextShowRefresh = false;
return;
+377 -4
View File
@@ -1,4 +1,377 @@
<!-- 页面编号G-03用途创建家谱两阶段创建的结果恢复链未闭合时保持关闭 -->
<template><view class="create-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="创建家谱" custom-back @back="backToGenealogies" /></view><view class="page-content"><view class="state-card"><text>创建家谱待结果恢复合同</text><text>创建家谱后还必须用真实响应中的 `genealogyId` 创建首位人物当前没有可靠的创建结果恢复查询 owner且不能从泛型我的家谱列表按名称猜 ID页面不再生成本地家谱或本地首位人物预览</text><AppButton block label="返回我的家谱" @click="backToGenealogies" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToGenealogies=()=>returnTo("G01");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.create-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
<!-- 页面编号G-03用途创建家谱首代人物需在服务端提供可恢复合同后另行录入 -->
<template>
<view class="create-page">
<GenealogyPageBackground />
<PageHeader title="创建家谱" custom-back @back="backToGenealogies" />
<view class="page-content">
<view class="create-card">
<text class="create-card__eyebrow">立谱信息</text>
<text class="create-card__title">为家族创建一部家谱</text>
<text class="create-card__note">创建成功后会回到我的家谱首代人物可在后续合同开放后再录入</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>姓氏</text>
<input v-model="form.surname" maxlength="4" placeholder="请输入姓氏" placeholder-class="placeholder" @input="clearFieldError('surname')" />
</view>
<text v-if="fieldErrors.surname" class="field-error">{{ fieldErrors.surname }}</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>谱名</text>
<input v-model="form.genealogyName" maxlength="24" placeholder="请输入家谱名称" placeholder-class="placeholder" @input="clearFieldError('genealogyName')" />
</view>
<text v-if="fieldErrors.genealogyName" class="field-error">{{ fieldErrors.genealogyName }}</text>
<view class="field-row field-row--selector" @click="openRegionPicker">
<text class="field-row__label"><text class="required-mark">*</text>所在地区</text>
<text class="region-selector-value" :class="{ 'region-selector-value--placeholder': !selectedRegion }">{{ selectedRegion ? regionPickerTrail.map((item) => item.label).join(' / ') : (regionLoading ? '正在加载地区…' : (regionPickerError || '请选择所在地区')) }}</text>
</view>
<text v-if="fieldErrors.regionCode" class="field-error">{{ fieldErrors.regionCode }}</text>
<view class="field-row">
<text class="field-row__label">堂号</text>
<input v-model="form.ancestralHall" maxlength="12" placeholder="请输入堂号" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input v-model="form.originPlace" maxlength="30" placeholder="请输入所在地" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input v-model="form.addressDetail" maxlength="60" placeholder="请输入详细地址" placeholder-class="placeholder" />
</view>
<view class="intro-field">
<text class="intro-field__label">家谱简介</text>
<textarea v-model="form.intro" maxlength="200" auto-height placeholder="可记录迁徙、家训或建谱缘由" placeholder-class="placeholder" />
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint">选择图片后会取得真实上传回执并在创建家谱时关联</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadCover">{{ isUploading ? '上传中' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="access-rule">
<text class="access-rule__label">访问规则</text>
<view class="access-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="access-rule__option"
:class="{ 'access-rule__option--active': form.accessPreset === option.value }"
@click="form.accessPreset = option.value"
>{{ option.label }}</view>
</view>
</view>
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在创建…' : '确认创建家谱'" @click="submitCreate" />
</view>
</view>
<view v-if="regionPickerOpen" class="region-sheet">
<view class="region-sheet__mask" />
<view class="region-sheet__panel">
<view class="region-sheet__intro">
<text class="region-sheet__title">选择地区</text>
</view>
<view class="region-sheet__picker">
<view class="region-sheet__column-headings">
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
</view>
<picker-view
class="region-sheet__picker-view"
:indicator-style="regionPickerIndicatorStyle"
:value="regionPickerIndexes"
@change="handleRegionPickerChange"
>
<picker-view-column
v-for="(column, columnIndex) in regionPickerColumns"
:key="columnIndex"
>
<view
v-for="(option, optionIndex) in column"
:key="option.regionCode"
class="region-sheet__picker-item"
:class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }"
>{{ option.label }}</view>
</picker-view-column>
</picker-view>
</view>
<view class="region-sheet__footer">
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { finishPage, returnTo } from "@/utils/navigation.js";
const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
originPlace: "",
addressDetail: "",
intro: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
const submitError = ref("");
const isSubmitting = ref(false);
const isUploading = ref(false);
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const createController = createRequestController();
const regionController = createRequestController();
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerColumns = ref([]);
const regionPickerIndexes = ref([0]);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerOpen = ref(false);
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
let pageActive = true;
const clearFieldError = (field) => {
fieldErrors[field] = "";
submitError.value = "";
};
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.regionCode = selectedRegion.value?.regionCode ? "" : "请选择所在地区";
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
};
const fetchRegionChildren = async (parentCode) => {
regionLoading.value = true;
regionPickerError.value = "";
try {
return await appApi.getRegionChildren(parentCode, { requestController: regionController });
} catch (error) {
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
return [];
} finally {
regionLoading.value = false;
}
};
const loadRegionRoot = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
const roots = await fetchRegionChildren("0");
if (!roots.length || regionPickerError.value) return;
regionPickerColumns.value = [roots];
regionPickerIndexes.value = [0];
regionPickerTrail.value = [];
await loadPickerColumns();
};
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
const province = provinces[provinceIndex];
if (!province) return;
const cities = await fetchRegionChildren(province.regionCode);
const city = cities[cityIndex];
if (!city) {
regionPickerColumns.value = [provinces];
regionPickerIndexes.value = [provinceIndex];
return;
}
const districts = await fetchRegionChildren(city.regionCode);
if (!districts.length || regionPickerError.value) return;
regionPickerColumns.value = [provinces, cities, districts];
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
};
const handleRegionPickerChange = async (event) => {
if (regionLoading.value) return;
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
const indexes = regionPickerIndexes.value;
const provinceIndex = nextIndexes[0] || 0;
const cityIndex = nextIndexes[1] || 0;
const districtIndex = nextIndexes[2] || 0;
if (provinceIndex !== (indexes[0] || 0)) {
await loadPickerColumns(provinceIndex, 0);
return;
}
if (cityIndex !== (indexes[1] || 0)) {
await loadPickerColumns(indexes[0] || 0, cityIndex);
return;
}
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
if (!regionPickerColumns.value.length) await loadRegionRoot();
if (regionPickerColumns.value.length) {
regionPickerOpen.value = true;
}
};
const closeRegionPicker = () => {
regionPickerOpen.value = false;
};
const confirmRegionSelection = () => {
const indexes = regionPickerIndexes.value;
const trail = [];
regionPickerColumns.value.forEach((column, index) => {
const option = column[Number(indexes[index])];
if (option) trail.push(option);
});
const region = trail[trail.length - 1];
if (!region) return;
regionPickerIndexes.value = trail.map((_, index) => Number(indexes[index]) || 0);
selectedRegion.value = region;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
regionPickerOpen.value = false;
};
onMounted(() => {
loadRegionRoot();
});
const backToGenealogies = () => {
if (!isSubmitting.value && !isUploading.value) return returnTo("G01");
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: createController });
if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitCreate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "访问规则无效,请重新选择";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
const created = await appApi.createGenealogy({
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value.regionCode,
ancestralHall: form.ancestralHall,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
coverOssId: coverOssId.value,
...access,
}, { requestController: createController });
if (!pageActive) return;
await finishPage("G01", {}, {
operation: "genealogy-created",
entityId: created.id,
refresh: true,
});
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = error?.message || "创建失败,请稍后重试";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
onUnload(() => {
pageActive = false;
createController.abort();
regionController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.create-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-content { z-index: 1; padding: 24rpx 32rpx 72rpx; }
.create-card { @include adaptive.adaptive-genealogy-state-panel; padding: 48rpx 38rpx 42rpx; }
.create-card__eyebrow, .create-card__title, .create-card__note, .field-error, .submit-error { display: block; }
.create-card__eyebrow { color: $brand-red; font-size: 26rpx; font-weight: 700; letter-spacing: 3rpx; }
.create-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 42rpx; font-weight: 700; }
.create-card__note { margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
.field-row { display: flex; min-height: 100rpx; align-items: center; margin-top: 22rpx; padding: 0 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
.field-row--selector { justify-content: space-between; cursor: pointer; }
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
.region-selector-value--placeholder { color: #ab9a86; }
.placeholder { color: #ab9a86; }
.intro-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.intro-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
.cover-field { display: grid; gap: 12rpx; margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
.access-rule { margin-top: 28rpx; }
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
.create-card .app-button { margin-top: 32rpx; }
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
.region-sheet__panel { position: relative; width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
.region-sheet__title { display: block; }
.region-sheet__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.region-sheet__picker { overflow: hidden; border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
.region-sheet__picker-view { width: 100%; height: 520rpx; }
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; overflow: hidden; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
.region-sheet__confirm::after { display: none; }
</style>
+67 -4
View File
@@ -1,4 +1,67 @@
<!-- 页面编号G-06用途搜索公开家谱公开列表 item DTO 未声明时保持关闭 -->
<template><view class="search-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="搜索家谱" custom-back @back="backToGenealogies" /></view><view class="page-content"><view class="state-card"><text>公开家谱搜索待后端字段合同</text><text>公开家谱读取没有声明家谱 ID名称地区简介成员数或可申请状态字段页面不再展示 fixture 搜索结果也不从猜测条目进入申请</text><AppButton block label="返回我的家谱" @click="backToGenealogies" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToGenealogies=()=>returnTo("G01");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.search-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
<template>
<view class="search-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="搜索家谱" custom-back @back="backToGenealogies" /></view>
<view class="page-content">
<view class="search-note"><text>公开家谱</text><text>以下结果来自服务端公开家谱列表</text></view>
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取公开家谱" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取公开家谱</text><AppButton block type="secondary" label="重新加载" @click="loadGenealogies" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂未找到公开家谱</text></view>
<view v-else class="result-list">
<view v-for="item in rows" :key="item.id" class="genealogy-card">
<view class="card-heading"><text>{{ item.name }}</text><text v-if="item.surname">{{ item.surname }}</text></view>
<text v-if="item.location" class="card-meta">{{ item.location }}</text>
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
<view class="card-footer"><text>{{ item.memberCount }} 位成员</text><AppButton :label="item.canManage ? '已在我的家谱' : '申请加入'" :disabled="item.canManage" @click="applyToJoin(item)" /></view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { openPage, returnTo } from "@/utils/navigation.js";
const rows = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const loadGenealogies = async () => {
controller.abort();
state.value = "loading";
try {
const result = await appApi.getPublicGenealogies({ requestController: controller });
if (!active) return;
rows.value = result.map((item) => ({
id: String(item.genealogyId),
name: String(item.genealogyName || "未命名家谱"),
surname: String(item.surname || ""),
location: String(item.regionFullName || item.regionName || item.originPlace || item.addressDetail || ""),
intro: String(item.intro || ""),
memberCount: Number.isSafeInteger(item.memberCount) ? item.memberCount : 0,
canManage: item.canManage === true
})).filter((item) => /^[1-9]\d*$/.test(item.id));
state.value = rows.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const applyToJoin = (item) => openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
const backToGenealogies = () => returnTo("G01");
onLoad(loadGenealogies);
onShow(() => { if (state.value !== "loading") loadGenealogies(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.search-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.search-note,.state-card,.genealogy-card{@include adaptive.adaptive-genealogy-state-panel}.search-note{display:flex;min-height:112rpx;flex-direction:column;justify-content:center;padding:20rpx 30rpx}.search-note text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:30rpx;font-weight:700}.search-note text:last-child{margin-top:6rpx;color:$ink-muted;font-size:21rpx}.state-card{display:flex;min-height:310rpx;flex-direction:column;align-items:center;justify-content:center;margin-top:18rpx;padding:42rpx;text-align:center;color:$ink;font-size:28rpx}.state-card .app-button{width:100%;margin-top:22rpx}.result-list{display:flex;flex-direction:column;gap:16rpx;margin-top:18rpx}.genealogy-card{padding:28rpx 32rpx}.card-heading{display:flex;align-items:baseline;justify-content:space-between;gap:18rpx}.card-heading text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:31rpx;font-weight:700;overflow-wrap:anywhere}.card-heading text:last-child{flex:0 0 auto;color:$brand-red;font-size:22rpx}.card-meta,.card-copy{display:block;color:$ink-muted;font-size:22rpx;line-height:1.5}.card-meta{margin-top:10rpx}.card-copy{margin-top:8rpx}.card-footer{display:flex;align-items:center;justify-content:space-between;gap:18rpx;margin-top:20rpx;color:$ink-muted;font-size:21rpx}.card-footer .app-button{min-width:180rpx}@media(max-width:340px){.page-content{padding-right:20rpx;padding-left:20rpx}.card-footer{align-items:flex-start;flex-direction:column}.card-footer .app-button{width:100%}}
</style>
+146 -4
View File
@@ -1,4 +1,146 @@
<!-- 页面编号G-08用途加入申请公开家谱详情/权限投影不完整时保持关闭 -->
<template><view class="join-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="申请加入家谱" custom-back @back="backToSearch" /></view><view class="page-content"><view class="state-card"><text>加入申请待后端字段合同</text><text>申请提交 operation 不等于可安全发起申请当前没有公开家谱详情可申请权限或稳定家谱条目 ID 的消费投影页面不再使用 fixture 家谱或本地申请成功提示</text><AppButton block label="返回搜索家谱" @click="backToSearch" /></view></view></view></template>
<script setup>import AppButton from "@/components/AppButton.vue";import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";import PageHeader from "@/components/PageHeader.vue";import {returnTo} from "@/utils/navigation.js";const backToSearch=()=>returnTo("G06");</script>
<style scoped lang="scss">@use "../../styles/adaptive-frame-profiles.scss" as adaptive;.join-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}</style>
<!-- 页面编号G-08用途 APP GenealogyJoinApplyBody 提交加入申请 -->
<template>
<view class="join-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="申请加入家谱" custom-back @back="requestBack" /></view>
<view class="page-content">
<view v-if="!hasValidContext" class="state-card">
<text>申请入口无效</text>
<text>没有取得可申请的家谱标识</text>
<AppButton block label="返回搜索家谱" @click="backToSearch" />
</view>
<view v-else-if="state === 'success'" class="state-card">
<text>申请已提交</text>
<text>服务端已确认本次加入申请可返回继续查看公开家谱</text>
<AppButton block label="返回搜索家谱" @click="backToSearch" />
</view>
<view v-else class="join-form">
<view class="form-heading">
<text>申请加入</text>
<text>{{ genealogyName || '当前家谱' }}</text>
</view>
<view v-for="field in fields" :key="field.key" class="form-field">
<text>{{ field.label }}</text>
<textarea
v-if="field.multiline"
v-model.trim="form[field.key]"
auto-height
:maxlength="field.maxlength"
:placeholder="field.placeholder"
:disabled="state === 'submitting'"
@input="error = ''"
/>
<input
v-else
v-model.trim="form[field.key]"
:type="field.inputType || 'text'"
:maxlength="field.maxlength"
:placeholder="field.placeholder"
:disabled="state === 'submitting'"
@input="error = ''"
/>
</view>
<text v-if="error" class="form-error">{{ error }}</text>
<AppButton block :disabled="state === 'submitting'" :label="state === 'submitting' ? '正在提交' : '提交申请'" @click="submit" />
</view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前申请?"
message="当前填写内容尚未提交,离开后会清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const genealogyName = ref("");
const state = ref("form");
const error = ref("");
const discardVisible = ref(false);
const form = reactive({ applicantName: "", phone: "", relationDesc: "", applyReason: "", inviterUserId: "" });
const fields = [
{ key: "applicantName", label: "申请人姓名", maxlength: 50, placeholder: "填写您的姓名" },
{ key: "phone", label: "手机号", inputType: "number", maxlength: 11, placeholder: "填写联系电话" },
{ key: "relationDesc", label: "关系说明", maxlength: 100, placeholder: "例如:本族成员" },
{ key: "applyReason", label: "申请理由", multiline: true, maxlength: 500, placeholder: "说明申请加入的原因" },
{ key: "inviterUserId", label: "邀请人编号", inputType: "number", maxlength: 20, placeholder: "如有邀请人可填写编号" },
];
const controller = createRequestController();
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const dirty = computed(() => Object.values(form).some((value) => value.trim()));
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const confirmDiscard = confirmation.confirm;
const cancelDiscard = confirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
genealogyName.value = String(query?.genealogyName || "");
});
const backToSearch = () => returnTo("G06");
const submit = async () => {
if (state.value === "submitting" || !hasValidContext.value) return;
state.value = "submitting";
error.value = "";
try {
await appApi.applyToJoin(genealogyId.value, { ...form }, { requestController: controller });
state.value = "success";
} catch (cause) {
if (!isRequestCancelled(cause)) {
state.value = "form";
error.value = cause?.message || "申请提交失败,请稍后重试。";
}
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value && state.value === "form",
submitting: state.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { controller.abort(); confirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.join-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card, .join-form { @include adaptive.adaptive-genealogy-state-panel; }
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.state-card text { display: block; }
.state-card text:first-child, .form-heading text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button, .join-form .app-button { margin-top: 28rpx; }
.join-form { padding: 30rpx; }
.form-heading { display: flex; flex-direction: column; gap: 8rpx; padding-bottom: 22rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .24); }
.form-heading text:last-child { color: $brand-red; font-size: 24rpx; }
.form-field { margin-top: 22rpx; }
.form-field > text { display: block; color: $ink; font-size: 24rpx; font-weight: 700; }
.form-field input, .form-field textarea { @include adaptive.adaptive-genealogy-form-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }
.form-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }
.form-field textarea { min-height: 120rpx; line-height: 1.55; }
.form-error { display: block; margin-top: 16rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
</style>
+42 -3
View File
@@ -1,10 +1,49 @@
<!-- 页面编号G-09用途我的申请列表 DTO 未声明时不展示 fixture 申请 -->
<!-- 页面编号G-09用途读取当前账号的加入申请 -->
<template>
<view class="applications-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="我的申请" custom-back @back="returnToGenealogies" /></view><view class="page-content"><view class="state-card"><text>申请列表待后端字段合同</text><text>申请读取响应没有声明申请 ID家谱名称申请时间状态或审核备注字段页面已停止展示本地申请和本地撤回结果</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view></view></view>
<view class="applications-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="我的申请" custom-back @back="returnToGenealogies" /></view>
<view class="page-content">
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取我的申请" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取我的申请</text><AppButton block type="secondary" label="重新加载" @click="loadApplications" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂无加入申请</text><text>申请记录会直接从服务端读取。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
<view v-else class="state-card"><text>已读取 {{ applications.length }} 条申请</text><text>申请详情接口尚未开放当前不会用本地数据补造申请内容</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
</view>
</view>
</template>
<script setup>
import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const returnToGenealogies=()=>returnTo("G01");
import { ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { returnTo } from "@/utils/navigation.js";
const applications = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const loadApplications = async () => {
controller.abort();
state.value = "loading";
try {
applications.value = await appApi.getMyJoinApplications({ requestController: controller });
if (!active) return;
state.value = applications.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToGenealogies = () => returnTo("G01");
onLoad(loadApplications);
onShow(() => { if (state.value !== "loading") loadApplications(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@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}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
+46 -3
View File
@@ -1,10 +1,53 @@
<!-- 页面编号G-10用途加入申请审核缺申请列表 DTO/ID 来源时保持关闭 -->
<!-- 页面编号G-10用途读取当前家谱待审核的加入申请 -->
<template>
<view class="review-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="申请审核" custom-back @back="returnToGenealogies" /></view><view class="page-content"><view class="state-card"><text>申请审核待后端字段合同</text><text>审核 operation 存在但没有可消费的申请列表 DTO 或稳定申请 ID 来源页面不再展示 fixture 申请或本地通过/拒绝预览</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view></view></view>
<view class="review-page">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="申请审核" custom-back @back="returnToGenealogies" /></view>
<view class="page-content">
<view v-if="!valid" class="state-card"><text>申请审核入口无效</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
<view v-else-if="state === 'loading'" class="state-card"><AppLoading text="正在读取待审核申请" /></view>
<view v-else-if="state === 'error'" class="state-card"><text>暂时无法读取待审核申请</text><AppButton block type="secondary" label="重新加载" @click="loadApplications" /></view>
<view v-else-if="state === 'empty'" class="state-card"><text>暂无待审核申请</text><text>待审核记录会直接从服务端读取。</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
<view v-else class="state-card"><text>已读取 {{ applications.length }} 条待审核申请</text><text>审核详情接口尚未开放当前不会用本地数据补造申请内容或审核结果</text><AppButton block label="返回我的家谱" @click="returnToGenealogies" /></view>
</view>
</view>
</template>
<script setup>
import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const returnToGenealogies=()=>returnTo("G01");
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const applications = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const loadApplications = async () => {
if (!valid.value) return;
controller.abort();
state.value = "loading";
try {
applications.value = await appApi.getPendingApplications(genealogyId.value, { requestController: controller });
if (!active) return;
state.value = applications.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.value = "error";
}
};
const returnToGenealogies = () => returnTo("G01");
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (valid.value) loadApplications(); });
onShow(() => { if (valid.value && state.value !== "loading") loadApplications(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@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}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
+386 -4
View File
@@ -1,11 +1,393 @@
<!-- 页面编号G-11用途家谱设置缺可靠读取 DTO 与封面上传 owner 时保持关闭 -->
<!-- 页面编号G-11用途 GenealogyUpdateBody 更新当前家谱 -->
<template>
<view class="settings-page"><GenealogyPageBackground /><view class="page-header"><PageHeader title="家谱设置" custom-back @back="returnToOverview" /></view><view class="page-content"><view class="state-card"><text>家谱设置待字段合同</text><text>更新 operation 已声明名称姓氏祠堂地区简介可见性等字段但当前没有可消费的概览 DTO 来安全预填也没有封面 `coverOssId` 的上传闭环页面不再以 fixture 编辑或本地预览保存</text><AppButton block :label="hasValidContext ? '返回家谱总览' : '返回上一页'" @click="returnToOverview" /></view></view></view>
<view class="settings-page" :class="`settings-state--${pageState}`">
<GenealogyPageBackground />
<view class="page-header"><PageHeader title="家谱设置" custom-back @back="returnToOverview" /></view>
<view class="page-content">
<AppLoading v-if="pageState === 'loading'" text="正在读取家谱设置" description="请稍候,正在同步当前家谱信息。" />
<view v-else-if="pageState === 'form'" class="settings-card">
<text class="settings-card__title">编辑家谱信息</text>
<text class="settings-card__note">带红色星号的内容不能为空其余内容可按需要补充</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>姓氏</text>
<input v-model="form.surname" maxlength="4" placeholder="请输入姓氏" placeholder-class="placeholder" @input="clearFieldError('surname')" />
</view>
<text v-if="fieldErrors.surname" class="field-error">{{ fieldErrors.surname }}</text>
<view class="field-row">
<text class="field-row__label"><text class="required-mark">*</text>谱名</text>
<input v-model="form.genealogyName" maxlength="24" placeholder="请输入家谱名称" placeholder-class="placeholder" @input="clearFieldError('genealogyName')" />
</view>
<text v-if="fieldErrors.genealogyName" class="field-error">{{ fieldErrors.genealogyName }}</text>
<view class="field-row field-row--selector" @click="openRegionPicker">
<text class="field-row__label"><text class="required-mark">*</text>所在地区</text>
<text class="region-selector-value" :class="{ 'region-selector-value--placeholder': !regionDisplay }">{{ regionDisplay || (regionLoading ? '正在加载地区…' : (regionPickerError || '请选择所在地区')) }}</text>
</view>
<text v-if="fieldErrors.regionCode" class="field-error">{{ fieldErrors.regionCode }}</text>
<view class="field-row">
<text class="field-row__label">堂号</text>
<input v-model="form.ancestralHall" maxlength="12" placeholder="请输入堂号" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input v-model="form.originPlace" maxlength="30" placeholder="请输入所在地" placeholder-class="placeholder" />
</view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input v-model="form.addressDetail" maxlength="60" placeholder="请输入详细地址" placeholder-class="placeholder" />
</view>
<view class="intro-field">
<text class="intro-field__label">家谱简介</text>
<textarea v-model="form.intro" maxlength="200" auto-height placeholder="记录家谱缘由、迁徙或家训" placeholder-class="placeholder" />
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint">{{ coverOssId ? '当前已关联封面;重新选择后会以新的真实上传回执更新。' : '选择图片后会取得真实上传回执,并在保存时关联。' }}</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadCover">{{ isUploading ? '上传中' : '选择图片' }}</button>
<text v-if="coverFileName" class="upload-receipt">已上传{{ coverFileName }}</text>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="access-rule">
<text class="access-rule__label">访问规则</text>
<view class="access-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="access-rule__option"
:class="{ 'access-rule__option--active': form.accessPreset === option.value }"
@click="form.accessPreset = option.value"
>{{ option.label }}</view>
</view>
</view>
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting || isUploading" :label="isSubmitting ? '正在保存…' : '保存设置'" @click="submitUpdate" />
</view>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<view v-if="regionPickerOpen" class="region-sheet">
<view class="region-sheet__mask" @click="closeRegionPicker" />
<view class="region-sheet__panel">
<view class="region-sheet__intro"><text class="region-sheet__title">选择地区</text></view>
<view class="region-sheet__picker">
<view class="region-sheet__column-headings">
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
</view>
<picker-view class="region-sheet__picker-view" :indicator-style="regionPickerIndicatorStyle" :value="regionPickerIndexes" @change="handleRegionPickerChange">
<picker-view-column v-for="(column, columnIndex) in regionPickerColumns" :key="columnIndex">
<view v-for="(option, optionIndex) in column" :key="option.regionCode" class="region-sheet__picker-item" :class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }">{{ option.label }}</view>
</picker-view-column>
</picker-view>
</view>
<view class="region-sheet__footer">
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToOverview=()=>hasValidContext.value?returnTo("G05",{genealogyId:genealogyId.value}):goBack();
import { computed, onMounted, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const pageState = ref("loading");
const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
originPlace: "",
addressDetail: "",
intro: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
const submitError = ref("");
const isSubmitting = ref(false);
const isUploading = ref(false);
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const currentRegionCode = ref("");
const currentRegionDisplay = ref("");
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerColumns = ref([]);
const regionPickerIndexes = ref([0]);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerOpen = ref(false);
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
const controller = createRequestController();
let pageActive = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const regionDisplay = computed(() =>
regionPickerTrail.value.length
? regionPickerTrail.value.map((item) => item.label).join(" / ")
: currentRegionDisplay.value,
);
const stateCopy = computed(() => {
if (pageState.value === "success") {
return { title: "家谱设置已保存", copy: "服务端已返回成功结果。", action: "返回家谱总览" };
}
if (!hasValidContext.value) {
return { title: "家谱入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" };
}
return { title: "家谱设置暂时无法读取", copy: "请检查网络后重新读取。", action: "重新读取" };
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
submitError.value = "";
};
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.regionCode = (selectedRegion.value?.regionCode || currentRegionCode.value) ? "" : "请选择所在地区";
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
};
const fetchRegionChildren = async (parentCode) => {
regionLoading.value = true;
regionPickerError.value = "";
try {
return await appApi.getRegionChildren(parentCode, { requestController: controller });
} catch (error) {
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
return [];
} finally {
regionLoading.value = false;
}
};
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
const province = provinces[provinceIndex];
if (!province) return;
const cities = await fetchRegionChildren(province.regionCode);
const city = cities[cityIndex];
if (!city) {
regionPickerColumns.value = [provinces];
regionPickerIndexes.value = [provinceIndex];
return;
}
const districts = await fetchRegionChildren(city.regionCode);
if (!districts.length || regionPickerError.value) return;
regionPickerColumns.value = [provinces, cities, districts];
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
};
const loadRegionRoot = async () => {
if (regionLoading.value) return;
const roots = await fetchRegionChildren("0");
if (!roots.length || regionPickerError.value) return;
regionPickerColumns.value = [roots];
regionPickerIndexes.value = [0];
await loadPickerColumns();
};
const handleRegionPickerChange = async (event) => {
if (regionLoading.value) return;
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
const indexes = regionPickerIndexes.value;
const provinceIndex = nextIndexes[0] || 0;
const cityIndex = nextIndexes[1] || 0;
const districtIndex = nextIndexes[2] || 0;
if (provinceIndex !== (indexes[0] || 0)) {
await loadPickerColumns(provinceIndex, 0);
return;
}
if (cityIndex !== (indexes[1] || 0)) {
await loadPickerColumns(indexes[0] || 0, cityIndex);
return;
}
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
if (!regionPickerColumns.value.length) await loadRegionRoot();
if (regionPickerColumns.value.length) regionPickerOpen.value = true;
};
const closeRegionPicker = () => { regionPickerOpen.value = false; };
const confirmRegionSelection = () => {
const trail = regionPickerColumns.value.map((column, index) => column[Number(regionPickerIndexes.value[index])]).filter(Boolean);
const region = trail[trail.length - 1];
if (!region) return;
selectedRegion.value = region;
currentRegionCode.value = region.regionCode;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
regionPickerOpen.value = false;
};
const loadSettings = async () => {
if (!hasValidContext.value) {
pageState.value = "invalid";
return;
}
pageState.value = "loading";
try {
const settings = await appApi.getGenealogySettings(genealogyId.value, { requestController: controller });
if (!pageActive) return;
Object.assign(form, {
surname: settings.surname,
genealogyName: settings.genealogyName,
ancestralHall: settings.ancestralHall,
originPlace: settings.originPlace,
addressDetail: settings.addressDetail,
intro: settings.intro,
accessPreset: settings.accessPreset || GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
currentRegionCode.value = settings.regionCode;
currentRegionDisplay.value = settings.regionFullName || settings.regionName;
coverOssId.value = settings.coverOssId;
pageState.value = "form";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageState.value = "error";
}
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: controller });
if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitUpdate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "访问规则无效,请重新选择";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.updateGenealogy(genealogyId.value, {
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
ancestralHall: form.ancestralHall,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
...access,
}, { requestController: controller });
if (!pageActive) return;
pageState.value = "success";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = error?.message || "家谱设置保存失败,请稍后重试";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const returnToOverview = () => hasValidContext.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
const handleStateAction = () => pageState.value === "success" ? returnToOverview() : loadSettings();
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
onMounted(() => { void loadSettings(); });
onUnload(() => {
pageActive = false;
controller.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.settings-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-genealogy-state-panel}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
.settings-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { flex: 1; padding: 24rpx 32rpx 72rpx; }
.settings-card, .state-card { @include adaptive.adaptive-genealogy-state-panel; box-sizing: border-box; }
.settings-card { padding: 42rpx 38rpx; }
.settings-card__title, .settings-card__note, .field-error, .submit-error { display: block; }
.settings-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; }
.settings-card__note { margin-top: 12rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.55; }
.field-row { display: flex; min-height: 100rpx; align-items: center; margin-top: 22rpx; padding: 0 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
.field-row--selector { justify-content: space-between; cursor: pointer; }
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
.region-selector-value--placeholder, .placeholder { color: #ab9a86; }
.intro-field, .cover-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.intro-field__label, .cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
.cover-field { display: grid; gap: 12rpx; }
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
.access-rule { margin-top: 28rpx; }
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
.settings-card .app-button { margin-top: 32rpx; }
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.state-card text { display: block; }
.state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
.region-sheet__panel { width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
.region-sheet__title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.region-sheet__picker { border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
.region-sheet__picker-view { width: 100%; height: 520rpx; }
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
.region-sheet__confirm::after { display: none; }
</style>