修改完成待测试
This commit is contained in:
+26
-7
@@ -197,6 +197,8 @@
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<LoginAdvertisement />
|
||||
|
||||
<view class="register-entry">
|
||||
<text>还没有账号?</text>
|
||||
<button
|
||||
@@ -278,6 +280,7 @@
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||
import LoginAdvertisement from "@/components/auth/LoginAdvertisement.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import {
|
||||
@@ -777,11 +780,23 @@ const submitWechatLogin = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
height: var(--app-viewport-height);
|
||||
min-height: 0;
|
||||
max-height: var(--app-viewport-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page :deep(.auth-shell__paper) {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
@@ -817,6 +832,7 @@ const submitWechatLogin = async () => {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
@@ -825,7 +841,7 @@ const submitWechatLogin = async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 88rpx;
|
||||
color: #80684f;
|
||||
color: #5d493b;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
@@ -893,7 +909,7 @@ const submitWechatLogin = async () => {
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
color: #9f968d;
|
||||
color: #766c63;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
@@ -925,7 +941,7 @@ const submitWechatLogin = async () => {
|
||||
}
|
||||
|
||||
.get-code--disabled {
|
||||
color: #9f968d;
|
||||
color: #766c63;
|
||||
}
|
||||
|
||||
.form-secondary-row {
|
||||
@@ -945,6 +961,7 @@ const submitWechatLogin = async () => {
|
||||
|
||||
.login-submit {
|
||||
display: grid;
|
||||
margin-top: 26rpx;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -973,6 +990,7 @@ const submitWechatLogin = async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 8rpx;
|
||||
color: #493323;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
@@ -987,8 +1005,9 @@ const submitWechatLogin = async () => {
|
||||
|
||||
.agreement-area {
|
||||
position: relative;
|
||||
margin-top: 2rpx;
|
||||
margin-bottom: 48rpx;
|
||||
color: #58483c;
|
||||
color: #493323;
|
||||
}
|
||||
|
||||
.agreement-row {
|
||||
@@ -1041,7 +1060,7 @@ const submitWechatLogin = async () => {
|
||||
}
|
||||
|
||||
.third-party-login__divider {
|
||||
color: rgba(76, 57, 41, 0.62);
|
||||
color: #5f4b3c;
|
||||
font-size: clamp(12px, 21rpx, 15px);
|
||||
}
|
||||
|
||||
@@ -1059,7 +1078,7 @@ const submitWechatLogin = async () => {
|
||||
}
|
||||
|
||||
.wechat-login[disabled] {
|
||||
opacity: 0.52;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.wechat-login__mark {
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<text class="article-card__content">{{
|
||||
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
|
||||
}}</text>
|
||||
<FamilyFeedMedia
|
||||
v-if="!article.contentProtected || article.contentUnlocked"
|
||||
:files="article.mediaFiles"
|
||||
image-label="查看谱文正文图片"
|
||||
/>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
<view
|
||||
v-if="article.canEdit || article.canDelete || article.canManageProtection"
|
||||
@@ -130,6 +135,7 @@ import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
|
||||
@@ -94,6 +94,35 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="editor-field editor-field--media">
|
||||
<view>
|
||||
<text class="editor-field__label">正文图片</text>
|
||||
<text class="editor-field__hint">图片按添加顺序显示,可单独移除。</text>
|
||||
</view>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || isSubmitting"
|
||||
@click="uploadArticleMedia"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加正文图片" }}
|
||||
</button>
|
||||
<view v-if="mediaReceipts.length" class="body-media-list">
|
||||
<view
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
class="body-media-item"
|
||||
>
|
||||
<text>{{ index + 1 }}. {{ receipt.fileName || "正文图片" }}</text>
|
||||
<button
|
||||
class="remove-media-button"
|
||||
:disabled="uploading || isSubmitting"
|
||||
:aria-label="`移除第${index + 1}张正文图片`"
|
||||
@click="removeArticleMedia(index)"
|
||||
>移除</button>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="mediaUploadError" class="editor-save-error">{{ mediaUploadError }}</text>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">作者名称</text>
|
||||
<view class="editor-control"
|
||||
@@ -104,6 +133,30 @@
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-if="!isEdit" class="editor-field">
|
||||
<text class="editor-field__label">内容密码(选填)</text>
|
||||
<text class="editor-field__hint">填写后会先创建谱文,再立即启用密码保护;密码设置失败时会明确提示。</text>
|
||||
<view class="editor-control">
|
||||
<input
|
||||
v-model="contentPassword"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="请输入8至128位内容密码"
|
||||
placeholder-class="editor-placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="editor-control editor-control--password-confirm">
|
||||
<input
|
||||
v-model="contentPasswordConfirm"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="请再次输入内容密码"
|
||||
placeholder-class="editor-placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="submitError" class="editor-save-error">{{
|
||||
submitError
|
||||
}}</text>
|
||||
@@ -178,8 +231,14 @@ const uploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const mediaUploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const mediaReceipts = ref([]);
|
||||
const contentPassword = ref("");
|
||||
const contentPasswordConfirm = ref("");
|
||||
const passwordSetupError = ref("");
|
||||
const passwordProtectionEnabled = ref(false);
|
||||
const categoryOptionsState = ref("loading");
|
||||
const categoryOptions = ref([]);
|
||||
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
|
||||
@@ -194,7 +253,9 @@ const form = reactive({
|
||||
const articleDetailController = createRequestController();
|
||||
const articleCategoryController = createRequestController();
|
||||
const articleCoverUploadController = createRequestController();
|
||||
const articleMediaUploadController = createRequestController();
|
||||
const articleSaveController = createRequestController();
|
||||
const articlePasswordController = createRequestController();
|
||||
const articleCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
|
||||
@@ -204,13 +265,20 @@ const categoryOptionIndex = computed(() => {
|
||||
});
|
||||
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
|
||||
const isEdit = computed(() => mode.value === "edit");
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
|
||||
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const isDirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Boolean(Object.values(form).some((value) => value.trim()) || coverOssId.value),
|
||||
: Boolean(
|
||||
Object.values(form).some((value) => value.trim()) ||
|
||||
coverOssId.value ||
|
||||
mediaReceipts.value.length ||
|
||||
contentPassword.value ||
|
||||
contentPasswordConfirm.value
|
||||
),
|
||||
);
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
@@ -219,10 +287,19 @@ const hasValidContext = computed(
|
||||
);
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "success"
|
||||
? {
|
||||
? passwordSetupError.value
|
||||
? {
|
||||
eyebrow: "谱文已创建",
|
||||
title: "密码保护尚未确认",
|
||||
copy: passwordSetupError.value,
|
||||
action: "打开谱文详情",
|
||||
}
|
||||
: {
|
||||
eyebrow: "保存成功",
|
||||
title: isEdit.value ? "谱文已更新" : "谱文已提交",
|
||||
copy: "已保存,返回后会显示最新内容。",
|
||||
copy: !isEdit.value && passwordProtectionEnabled.value
|
||||
? "谱文已保存,并已启用内容密码。"
|
||||
: "已保存,返回后会显示最新内容。",
|
||||
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
|
||||
}
|
||||
: editorState.value === "error"
|
||||
@@ -313,6 +390,10 @@ const loadArticleForEdit = async () => {
|
||||
coverFileName.value = article.coverFile
|
||||
? article.coverFile.fileName || "当前封面图片"
|
||||
: "";
|
||||
mediaReceipts.value = article.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName || "正文图片",
|
||||
}));
|
||||
preservedUpdateFields.value = {
|
||||
sortOrder: article.sortOrder,
|
||||
status: article.status,
|
||||
@@ -352,6 +433,35 @@ const clearCover = () => {
|
||||
uploadError.value = "";
|
||||
};
|
||||
|
||||
const uploadArticleMedia = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
mediaUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: articleMediaUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (mediaReceipts.value.some((item) => item.ossId === receipt.ossId)) {
|
||||
mediaUploadError.value = "这张图片已经添加。";
|
||||
return;
|
||||
}
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
mediaUploadError.value = getRequestErrorMessage(error, "正文图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeArticleMedia = (index) => {
|
||||
if (uploading.value || isSubmitting.value || !Number.isInteger(index)) return;
|
||||
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
|
||||
mediaUploadError.value = "";
|
||||
};
|
||||
|
||||
const saveArticle = async () => {
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
@@ -363,6 +473,7 @@ const saveArticle = async () => {
|
||||
const payload = {
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||
};
|
||||
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
|
||||
@@ -371,18 +482,48 @@ const saveArticle = async () => {
|
||||
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (contentPassword.value || contentPasswordConfirm.value) {
|
||||
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
|
||||
submitError.value = "内容密码必须为8至128位。";
|
||||
return;
|
||||
}
|
||||
if (contentPassword.value !== contentPasswordConfirm.value) {
|
||||
submitError.value = "两次输入的内容密码不一致。";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
passwordSetupError.value = "";
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
|
||||
requestController: articleSaveController,
|
||||
});
|
||||
} else {
|
||||
await familyArticleApi.createArticle(genealogyId.value, payload, {
|
||||
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
|
||||
requestController: articleSaveController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
articleId.value = createdArticle.id;
|
||||
if (contentPassword.value) {
|
||||
try {
|
||||
await familyArticleApi.setArticlePassword(
|
||||
genealogyId.value,
|
||||
createdArticle.id,
|
||||
contentPassword.value,
|
||||
{ requestController: articlePasswordController },
|
||||
);
|
||||
passwordProtectionEnabled.value = true;
|
||||
} catch (passwordError) {
|
||||
if (!pageActive || isRequestCancelled(passwordError)) return;
|
||||
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
|
||||
passwordSetupError.value = `谱文已经创建,但${failureCopy}。请进入详情重新设置;当前内容可能尚未受到密码保护。`;
|
||||
}
|
||||
contentPassword.value = "";
|
||||
contentPasswordConfirm.value = "";
|
||||
}
|
||||
}
|
||||
if (!pageActive) return;
|
||||
editorState.value = "success";
|
||||
@@ -412,7 +553,7 @@ const requestBack = () =>
|
||||
});
|
||||
const handleResultAction = () =>
|
||||
editorState.value === "success"
|
||||
? isEdit.value
|
||||
? isEdit.value || passwordSetupError.value
|
||||
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||
: returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: isEdit.value
|
||||
@@ -424,7 +565,9 @@ onUnload(() => {
|
||||
articleDetailController.abort();
|
||||
articleCategoryController.abort();
|
||||
articleCoverUploadController.abort();
|
||||
articleMediaUploadController.abort();
|
||||
articleSaveController.abort();
|
||||
articlePasswordController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -532,7 +675,8 @@ onUnload(() => {
|
||||
.editor-control--article textarea {
|
||||
min-height: 180rpx;
|
||||
}
|
||||
.editor-field--cover {
|
||||
.editor-field--cover,
|
||||
.editor-field--media {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@@ -540,7 +684,8 @@ onUnload(() => {
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.7);
|
||||
}
|
||||
.editor-field--cover .editor-field__label {
|
||||
.editor-field--cover .editor-field__label,
|
||||
.editor-field--media .editor-field__label {
|
||||
margin: 0;
|
||||
}
|
||||
.editor-field__hint {
|
||||
@@ -565,6 +710,7 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.editor-control--password-confirm { margin-top: 14rpx; }
|
||||
.remove-cover-button {
|
||||
justify-self: start;
|
||||
min-height: 72rpx;
|
||||
@@ -577,6 +723,39 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.remove-cover-button::after { border: 0; }
|
||||
.body-media-list {
|
||||
display: grid;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.body-media-item {
|
||||
display: flex;
|
||||
min-height: 66rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 8rpx 12rpx 8rpx 18rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 248, 0.58);
|
||||
}
|
||||
.body-media-item > text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.remove-media-button {
|
||||
min-width: var(--app-touch-min);
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.remove-media-button::after { border: 0; }
|
||||
.editor-placeholder {
|
||||
color: #9e8e79;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,32 @@
|
||||
</picker>
|
||||
</view>
|
||||
<view v-if="listState === 'list'" class="article-list-items">
|
||||
<BatchManagementBar
|
||||
v-if="deletableArticles.length"
|
||||
resource-name="谱文"
|
||||
:active="articleBatch.selectionMode.value"
|
||||
:selected-count="articleBatch.selectedCount.value"
|
||||
:all-selected="articleBatch.allSelected.value"
|
||||
:busy="articleBatch.deleting.value"
|
||||
@start="articleBatch.enterSelectionMode"
|
||||
@finish="articleBatch.exitSelectionMode"
|
||||
@toggle-all="articleBatch.toggleAll"
|
||||
@delete="articleBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="articleBatch.notice.value" class="article-batch-notice" role="status">{{ articleBatch.notice.value }}</text>
|
||||
<text v-if="articleBatch.error.value" class="article-batch-error" role="alert">{{ articleBatch.error.value }}</text>
|
||||
<view
|
||||
v-for="item in filteredArticles"
|
||||
:key="item.id"
|
||||
class="article-card"
|
||||
@click="openArticle(item)"
|
||||
@click="handleArticleClick(item)"
|
||||
>
|
||||
<BatchSelectionMark
|
||||
v-if="articleBatch.selectionMode.value && item.canDelete"
|
||||
:selected="articleBatch.isSelected(item)"
|
||||
:label="`谱文:${item.title}`"
|
||||
@toggle="articleBatch.toggleSelection(item)"
|
||||
/>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="article-card__cover"
|
||||
@@ -56,6 +76,18 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="articleBatch.confirmationVisible.value"
|
||||
:close-on-mask="false"
|
||||
eyebrow="批量删除"
|
||||
title="将选中的谱文移入回收站?"
|
||||
:message="articleBatch.confirmationMessage.value"
|
||||
:confirm-text="articleBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
@confirm="articleBatch.confirmDelete"
|
||||
@cancel="articleBatch.cancelDelete"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -63,6 +95,9 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
@@ -71,6 +106,7 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -82,6 +118,7 @@ const articleCategoryError = ref("");
|
||||
const selectedCategoryId = ref("");
|
||||
const articleListRequestController = createRequestController();
|
||||
const articleCategoryRequestController = createRequestController();
|
||||
const articleDeleteRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let skipInitialShowRefresh = true;
|
||||
const categoryOptions = computed(() => [
|
||||
@@ -98,6 +135,20 @@ const filteredArticles = computed(() =>
|
||||
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
|
||||
: articles.value,
|
||||
);
|
||||
const articleBatch = useBatchDeletion({
|
||||
items: articles,
|
||||
visibleItems: filteredArticles,
|
||||
deleteOne: (article) =>
|
||||
familyArticleApi.deleteArticle(genealogyId.value, article.id, {
|
||||
requestController: articleDeleteRequestController,
|
||||
}),
|
||||
resourceName: "谱文",
|
||||
isActive: () => pageActive,
|
||||
onEmpty: () => {
|
||||
listState.value = "empty";
|
||||
},
|
||||
});
|
||||
const deletableArticles = articleBatch.deletableItems;
|
||||
const stateCopy = computed(() =>
|
||||
hasValidContext.value
|
||||
? listState.value === "empty"
|
||||
@@ -140,6 +191,7 @@ onUnload(() => {
|
||||
pageActive = false;
|
||||
articleListRequestController.abort();
|
||||
articleCategoryRequestController.abort();
|
||||
articleDeleteRequestController.abort();
|
||||
});
|
||||
const loadArticleCategories = async () => {
|
||||
articleCategoryError.value = "";
|
||||
@@ -169,6 +221,7 @@ const loadArticles = async () => {
|
||||
]);
|
||||
if (!pageActive) return;
|
||||
articles.value = rows;
|
||||
articleBatch.exitSelectionMode();
|
||||
if (categoryRows) categories.value = categoryRows;
|
||||
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = "";
|
||||
@@ -181,6 +234,7 @@ const loadArticles = async () => {
|
||||
};
|
||||
const selectCategory = (event) => {
|
||||
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
|
||||
articleBatch.exitSelectionMode();
|
||||
};
|
||||
const createArticle = () =>
|
||||
hasValidContext.value
|
||||
@@ -188,6 +242,13 @@ const createArticle = () =>
|
||||
: Promise.resolve(false);
|
||||
const openArticle = (item) =>
|
||||
openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
|
||||
const handleArticleClick = (item) => {
|
||||
if (articleBatch.selectionMode.value && item?.canDelete) {
|
||||
articleBatch.toggleSelection(item);
|
||||
return;
|
||||
}
|
||||
openArticle(item);
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
if (listState.value === "error") return loadArticles();
|
||||
@@ -214,6 +275,15 @@ const handleStateAction = () => {
|
||||
.article-list-items {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.article-batch-notice,
|
||||
.article-batch-error {
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.article-batch-notice { color: #426538; }
|
||||
.article-batch-error { color: $brand-red; }
|
||||
.article-category-error {
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
|
||||
@@ -291,7 +291,7 @@ onUnload(() => {
|
||||
|
||||
const backToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
? returnTo("F12", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = () => backToFamily();
|
||||
const handleStateAction = () =>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<view>
|
||||
<text class="publish-field__label">动态配图</text>
|
||||
<text class="publish-field__hint"
|
||||
>图片上传成功后,会随动态一起发布。</text
|
||||
>{{ isEdit ? "可新增、预览或移除图片,保存后生效。" : "图片上传成功后,会随动态一起发布。" }}</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -42,12 +42,49 @@
|
||||
>
|
||||
{{ isUploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
class="upload-receipt"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<template v-if="isEdit">
|
||||
<view v-if="mediaReceipts.length" class="media-preview-grid">
|
||||
<view
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
class="media-preview-card"
|
||||
>
|
||||
<button
|
||||
class="media-preview-card__preview"
|
||||
:disabled="isUploading || isSubmitting || !getMediaPreviewUrl(receipt)"
|
||||
:aria-label="`预览第${index + 1}张动态配图`"
|
||||
@click="previewMedia(receipt)"
|
||||
>
|
||||
<image
|
||||
v-if="getMediaPreviewUrl(receipt)"
|
||||
class="media-preview-card__image"
|
||||
:src="getMediaPreviewUrl(receipt)"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="media-preview-card__fallback">
|
||||
<text>图片暂不可预览</text>
|
||||
</view>
|
||||
</button>
|
||||
<view class="media-preview-card__meta">
|
||||
<text>{{ receipt.fileName || `动态配图${index + 1}` }}</text>
|
||||
<button
|
||||
class="media-preview-card__remove"
|
||||
:disabled="isUploading || isSubmitting"
|
||||
:aria-label="`移除第${index + 1}张动态配图`"
|
||||
@click="removeMedia(index)"
|
||||
>移除</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
class="upload-receipt"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
</template>
|
||||
<text v-if="uploadError" class="publish-error">{{
|
||||
uploadError
|
||||
}}</text>
|
||||
@@ -204,6 +241,7 @@ const loadEditFeed = async (feedId) => {
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
accessUrl: file.accessUrl,
|
||||
}));
|
||||
editingFeed.value = {
|
||||
id: detail.id,
|
||||
@@ -254,6 +292,22 @@ const uploadImage = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getMediaPreviewUrl = (receipt) =>
|
||||
String(receipt?.thumbnailUrl || receipt?.accessUrl || receipt?.url || "").trim();
|
||||
|
||||
const previewMedia = (receipt) => {
|
||||
const current = getMediaPreviewUrl(receipt);
|
||||
if (!current) return;
|
||||
const urls = mediaReceipts.value.map(getMediaPreviewUrl).filter(Boolean);
|
||||
uni.previewImage({ current, urls });
|
||||
};
|
||||
|
||||
const removeMedia = (index) => {
|
||||
if (isUploading.value || isSubmitting.value || !Number.isInteger(index)) return;
|
||||
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
|
||||
uploadError.value = "";
|
||||
};
|
||||
|
||||
const saveFeed = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||
if (!form.feedContent.trim()) {
|
||||
@@ -322,7 +376,7 @@ const requestBack = () =>
|
||||
const returnToFamily = async () => {
|
||||
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
||||
if (!confirmed) return false;
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
return returnTo("F12", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (publishState.value === "success") {
|
||||
@@ -474,6 +528,89 @@ onUnload(() => {
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.media-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
}
|
||||
.media-preview-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(255, 253, 248, 0.62);
|
||||
}
|
||||
.media-preview-card__preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 176rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: rgba(128, 89, 49, 0.1);
|
||||
line-height: 1;
|
||||
}
|
||||
.media-preview-card__preview::after,
|
||||
.media-preview-card__remove::after {
|
||||
border: 0;
|
||||
}
|
||||
.media-preview-card__preview:focus-visible,
|
||||
.media-preview-card__remove:focus-visible {
|
||||
outline: 2rpx solid $brand-red;
|
||||
outline-offset: -2rpx;
|
||||
}
|
||||
.media-preview-card__preview:active:not([disabled]) {
|
||||
background: rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.media-preview-card__preview[disabled],
|
||||
.media-preview-card__remove[disabled] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.media-preview-card__image,
|
||||
.media-preview-card__fallback {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.media-preview-card__fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 19rpx, 15px);
|
||||
}
|
||||
.media-preview-card__meta {
|
||||
display: flex;
|
||||
min-height: 72rpx;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 6rpx 6rpx 6rpx 14rpx;
|
||||
}
|
||||
.media-preview-card__meta > text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 19rpx, 15px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-preview-card__remove {
|
||||
min-width: var(--app-touch-min);
|
||||
min-height: var(--app-touch-min);
|
||||
margin: 0;
|
||||
padding: 0 10rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-preview-card__remove:active:not([disabled]) {
|
||||
background: rgba(159, 23, 15, 0.08);
|
||||
}
|
||||
.publish-placeholder {
|
||||
color: #8e806e;
|
||||
}
|
||||
|
||||
+14
-89
@@ -3,25 +3,16 @@
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="family-page__header"
|
||||
><PageHeader
|
||||
root
|
||||
title="家族动态"
|
||||
custom-back
|
||||
:action="hasValidContext ? '发布' : ''"
|
||||
@back="requestBack"
|
||||
@action="toPublish"
|
||||
/></view>
|
||||
<view class="feed-content">
|
||||
<view class="feed-heading"
|
||||
><text>家族圈</text><text>家宴、通知与共同记忆</text></view
|
||||
>
|
||||
<view v-if="hasValidContext" class="feed-shortcuts"
|
||||
><button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.key"
|
||||
class="feed-shortcut"
|
||||
:aria-label="`打开${item.label}`"
|
||||
@click="openSection(item.key)"
|
||||
><text>{{ item.label }}</text></button
|
||||
></view
|
||||
>
|
||||
<AppLoading
|
||||
v-if="feedState === 'loading'"
|
||||
text="正在读取家族动态"
|
||||
@@ -57,15 +48,13 @@
|
||||
><text>{{ stateCopy.action }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppTabbar active="family" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<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 AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
@@ -75,8 +64,7 @@ import {
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
||||
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
@@ -108,17 +96,6 @@ const feedPreview = (content) => {
|
||||
const text = String(content || "").trim();
|
||||
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}…` : text;
|
||||
};
|
||||
const shortcuts = [
|
||||
{ key: "articles", label: "谱文" },
|
||||
{ key: "albums", label: "相册" },
|
||||
{ key: "rituals", label: "礼仪" },
|
||||
{ key: "memos", label: "备忘" },
|
||||
{ key: "benefactors", label: "家族恩人" },
|
||||
{ key: "people", label: "人物录" },
|
||||
{ key: "gifts", label: "贺礼簿" },
|
||||
{ key: "merits", label: "功德录" },
|
||||
{ key: "videos", label: "家族视频" },
|
||||
];
|
||||
const stateCopy = computed(() =>
|
||||
!hasValidContext.value
|
||||
? {
|
||||
@@ -205,13 +182,7 @@ const loadMoreFeeds = async () => {
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
const supplied = Object.prototype.hasOwnProperty.call(
|
||||
query || {},
|
||||
"genealogyId",
|
||||
);
|
||||
genealogyId.value = supplied
|
||||
? String(query.genealogyId || "")
|
||||
: String(genealogyContext.getCurrentGenealogyId() || "");
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||
if (hasValidContext.value) void loadFeeds();
|
||||
else feedState.value = "error";
|
||||
@@ -230,33 +201,16 @@ onUnload(() => {
|
||||
});
|
||||
const toPublish = () =>
|
||||
hasValidContext.value
|
||||
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F01")
|
||||
: goRoot("G01");
|
||||
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F12")
|
||||
: goBack();
|
||||
const openFeed = (item) =>
|
||||
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
|
||||
const openSection = (key) => {
|
||||
const routes = {
|
||||
articles: "F04",
|
||||
albums: "F07",
|
||||
rituals: "R05",
|
||||
memos: "R10",
|
||||
benefactors: "R10",
|
||||
people: "R01",
|
||||
gifts: "R03",
|
||||
merits: "R11",
|
||||
videos: "F10",
|
||||
};
|
||||
return openPage(
|
||||
routes[key],
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
|
||||
},
|
||||
"F01",
|
||||
);
|
||||
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F12");
|
||||
const handlePrimaryAction = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
return feedState.value === "error" ? loadFeeds() : toPublish();
|
||||
};
|
||||
const handlePrimaryAction = () =>
|
||||
feedState.value === "error" ? loadFeeds() : toPublish();
|
||||
const requestBack = () => goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -273,7 +227,7 @@ const handlePrimaryAction = () =>
|
||||
}
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
|
||||
padding: 24rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.feed-heading text {
|
||||
display: block;
|
||||
@@ -289,29 +243,6 @@ const handlePrimaryAction = () =>
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.feed-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.feed-shortcut {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.45);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 252, 244, 0.58);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.feed-shortcut text {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-list {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
@@ -347,12 +278,6 @@ const handlePrimaryAction = () =>
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.feed-shortcut::after {
|
||||
border: 0;
|
||||
}
|
||||
.feed-shortcut:active {
|
||||
background: rgba(181, 137, 63, 0.1);
|
||||
}
|
||||
.feed-card__reason {
|
||||
margin-top: 8rpx;
|
||||
color: #9a6555;
|
||||
|
||||
@@ -40,12 +40,21 @@
|
||||
/>
|
||||
<view class="video-card__play" aria-hidden="true"></view>
|
||||
</view>
|
||||
<video
|
||||
<view
|
||||
v-else
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
class="video-card__player"
|
||||
/>
|
||||
class="video-card__cover-button video-card__cover-placeholder"
|
||||
role="button"
|
||||
:aria-label="`播放${video.title}`"
|
||||
hover-class="action-hover"
|
||||
@click="openVerticalViewer(video)"
|
||||
>
|
||||
<image
|
||||
class="video-card__placeholder-seal"
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="video-card__play" aria-hidden="true"></view>
|
||||
</view>
|
||||
<text class="video-card__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
|
||||
<text v-if="video.startAt" class="video-card__meta">发布时间:{{ video.startAt }}</text>
|
||||
@@ -423,12 +432,6 @@ onUnload(() => {
|
||||
.video-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
.video-card__player {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #1f1b17;
|
||||
}
|
||||
.video-card__cover-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -437,6 +440,17 @@ onUnload(() => {
|
||||
background: #1f1b17;
|
||||
overflow: hidden;
|
||||
}
|
||||
.video-card__cover-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #f4e5cd, #d8b77c);
|
||||
}
|
||||
.video-card__placeholder-seal {
|
||||
width: 132rpx;
|
||||
height: 152rpx;
|
||||
opacity: .72;
|
||||
}
|
||||
.video-card__cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SiteHome subpage-mode />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SiteHome from "@/pages/family/site-home.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<view class="site-home-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<PageHeader :root="!subpageMode" :custom-back="subpageMode" :title="headerTitle" />
|
||||
|
||||
<scroll-view class="site-home-scroll" scroll-y>
|
||||
<view class="site-home-content" :class="{ 'site-home-content--root': !subpageMode }">
|
||||
<view class="site-home-heading">
|
||||
<text>{{ pageTitle }}</text>
|
||||
<text>{{ pageDescription }}</text>
|
||||
</view>
|
||||
|
||||
<AppLoading
|
||||
v-if="articleState === 'loading'"
|
||||
text="正在整理资讯"
|
||||
description="请稍候,正在读取最新内容。"
|
||||
/>
|
||||
|
||||
<view v-else-if="articleState === 'list'" class="site-article-list">
|
||||
<button
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
class="site-article-card"
|
||||
:aria-label="`${article.title},查看文章`"
|
||||
@click="openArticle(article)"
|
||||
>
|
||||
<view class="site-article-card__meta">
|
||||
<text>{{ article.typeLabel }}</text>
|
||||
<text v-if="article.publishTime">{{ formatArticleDate(article.publishTime) }}</text>
|
||||
</view>
|
||||
<text class="site-article-card__title">{{ article.title }}</text>
|
||||
<text v-if="article.summary" class="site-article-card__summary">
|
||||
{{ article.summary }}
|
||||
</text>
|
||||
<text class="site-article-card__action">
|
||||
{{ article.externalUrl ? "前往阅读" : "阅读全文" }} ›
|
||||
</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="site-home-state">
|
||||
<text>{{ articleState === "empty" ? "暂时没有资讯" : "资讯暂时无法读取" }}</text>
|
||||
<text>{{ articleState === "empty" ? "新内容发布后会在这里展示。" : articleError }}</text>
|
||||
<button v-if="articleState === 'error'" @click="loadArticles">重新加载</button>
|
||||
</view>
|
||||
|
||||
<text v-if="openError" class="site-home-error" role="alert">{{ openError }}</text>
|
||||
<AppPromotionStrip placement="home_bottom" title="更多内容" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(selectedArticle)"
|
||||
:eyebrow="selectedArticle?.typeLabel || '传承资讯'"
|
||||
:title="selectedArticle?.title || '文章详情'"
|
||||
:message="selectedArticleContent"
|
||||
confirm-text="关闭"
|
||||
@confirm="closeArticle"
|
||||
@close="closeArticle"
|
||||
/>
|
||||
<AppTabbar v-if="!subpageMode" active="family" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({
|
||||
subpageMode: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const subpageMode = computed(() => props.subpageMode);
|
||||
|
||||
const articleState = ref("loading");
|
||||
const articleType = ref("");
|
||||
const pageTitle = ref("传承资讯");
|
||||
const articleError = ref("");
|
||||
const openError = ref("");
|
||||
const articles = ref([]);
|
||||
const selectedArticle = ref(null);
|
||||
const articleRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const decodeRouteText = (value) => {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
try {
|
||||
return decodeURIComponent(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const headerTitle = computed(() => (subpageMode.value ? pageTitle.value : "代代相传"));
|
||||
|
||||
const pageDescription = computed(() =>
|
||||
articleType.value === "notice"
|
||||
? "平台发布的网站公告与服务通知"
|
||||
: articleType.value === "news"
|
||||
? "家谱文化、平台动态与最新资讯"
|
||||
: "家谱文化、平台动态与实用文章",
|
||||
);
|
||||
|
||||
const decodeArticleEntities = (value) =>
|
||||
value
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'");
|
||||
|
||||
const articlePlainText = (value) =>
|
||||
decodeArticleEntities(
|
||||
String(value || "")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(?:p|div|li|h[1-6])>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, ""),
|
||||
)
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
const selectedArticleContent = computed(() =>
|
||||
articlePlainText(selectedArticle.value?.content || selectedArticle.value?.summary) ||
|
||||
"这篇文章暂时没有正文。",
|
||||
);
|
||||
|
||||
const formatArticleDate = (value) => {
|
||||
const text = String(value || "").trim();
|
||||
return text.length >= 10 ? text.slice(0, 10) : text;
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
articleRequestController.abort();
|
||||
articleState.value = "loading";
|
||||
articleError.value = "";
|
||||
try {
|
||||
const rows = await siteContentApi.getSiteArticles({
|
||||
limit: 20,
|
||||
...(articleType.value ? { articleType: articleType.value } : {}),
|
||||
requestController: articleRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
articles.value = rows;
|
||||
articleState.value = rows.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
articleError.value = getRequestErrorMessage(error, "请稍后重新读取。");
|
||||
articleState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openArticle = async (article) => {
|
||||
openError.value = "";
|
||||
if (!article.externalUrl) {
|
||||
selectedArticle.value = article;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openSiteContentTarget(article.externalUrl, () => {
|
||||
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
|
||||
});
|
||||
} catch {
|
||||
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
|
||||
}
|
||||
};
|
||||
|
||||
const closeArticle = () => {
|
||||
selectedArticle.value = null;
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
articleType.value = subpageMode.value && ["news", "notice"].includes(String(query?.articleType || ""))
|
||||
? String(query.articleType)
|
||||
: "";
|
||||
pageTitle.value = subpageMode.value
|
||||
? decodeRouteText(query?.title) ||
|
||||
(articleType.value === "notice" ? "网站公告" : articleType.value === "news" ? "家谱新闻" : "文化传承文章")
|
||||
: "传承资讯";
|
||||
void loadArticles();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
articleRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.site-home-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
.site-home-scroll {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.site-home-content {
|
||||
padding: 22rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.site-home-content--root {
|
||||
padding-bottom: calc(166rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.site-home-heading text,
|
||||
.site-article-card text,
|
||||
.site-home-state text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-home-heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(20px, 36rpx, 25px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-home-heading text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
|
||||
.site-article-list {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.site-article-card {
|
||||
@include adaptive.adaptive-family-letter;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 210rpx;
|
||||
margin: 0 0 18rpx;
|
||||
padding: 28rpx 30rpx;
|
||||
border: 0;
|
||||
background-color: rgba($paper, 0.86);
|
||||
box-sizing: border-box;
|
||||
color: $ink;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.site-article-card::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.site-article-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
color: #946c48;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
|
||||
.site-article-card__title {
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(18px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-article-card__summary {
|
||||
display: -webkit-box;
|
||||
margin-top: 9rpx;
|
||||
overflow: hidden;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.site-article-card__action {
|
||||
margin-top: 14rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.site-home-state {
|
||||
display: flex;
|
||||
min-height: 420rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $ink-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-home-state text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-home-state text + text {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.site-home-state button {
|
||||
min-height: 72rpx;
|
||||
margin-top: 24rpx;
|
||||
padding: 0 30rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
border-radius: 36rpx;
|
||||
background: rgba(255, 250, 240, 0.8);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
|
||||
.site-home-state button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.site-home-error {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-home-content :deep(.promotion-strip) {
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
+62
-4
@@ -129,6 +129,20 @@
|
||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||
</view>
|
||||
<view v-else class="video-card-list">
|
||||
<BatchManagementBar
|
||||
v-if="deletableVideos.length"
|
||||
resource-name="视频"
|
||||
:active="videoBatch.selectionMode.value"
|
||||
:selected-count="videoBatch.selectedCount.value"
|
||||
:all-selected="videoBatch.allSelected.value"
|
||||
:busy="videoBatch.deleting.value"
|
||||
@start="videoBatch.enterSelectionMode"
|
||||
@finish="videoBatch.exitSelectionMode"
|
||||
@toggle-all="videoBatch.toggleAll"
|
||||
@delete="videoBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="videoBatch.notice.value" class="batch-notice" role="status">{{ videoBatch.notice.value }}</text>
|
||||
<text v-if="videoBatch.error.value" class="field-error" role="alert">{{ videoBatch.error.value }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
@@ -136,10 +150,16 @@
|
||||
@click="openVerticalViewer(videos[0])"
|
||||
/>
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<BatchSelectionMark
|
||||
v-if="videoBatch.selectionMode.value && video.canDelete"
|
||||
:selected="videoBatch.isSelected(video)"
|
||||
:label="`视频:${video.title}`"
|
||||
@toggle="videoBatch.toggleSelection(video)"
|
||||
/>
|
||||
<button
|
||||
class="video-card__cover-action"
|
||||
:aria-label="`播放${video.title}`"
|
||||
@click="openVerticalViewer(video)"
|
||||
@click="videoBatch.selectionMode.value && video.canDelete ? videoBatch.toggleSelection(video) : openVerticalViewer(video)"
|
||||
>
|
||||
<image
|
||||
class="video-card__cover"
|
||||
@@ -156,7 +176,7 @@
|
||||
video.publishTime || "刚刚发布"
|
||||
}}</text>
|
||||
<view
|
||||
v-if="video.canEdit || video.canDelete"
|
||||
v-if="!videoBatch.selectionMode.value && (video.canEdit || video.canDelete)"
|
||||
class="video-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
@@ -175,7 +195,7 @@
|
||||
@click="requestDeleteVideo(video)"
|
||||
/>
|
||||
</view>
|
||||
<view class="video-card__actions">
|
||||
<view v-if="!videoBatch.selectionMode.value" class="video-card__actions">
|
||||
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
|
||||
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
|
||||
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
|
||||
@@ -192,6 +212,18 @@
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="videoBatch.confirmationVisible.value"
|
||||
eyebrow="批量删除"
|
||||
title="将选中的视频移入回收站?"
|
||||
:message="videoBatch.confirmationMessage.value"
|
||||
:confirm-text="videoBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="videoBatch.confirmDelete"
|
||||
@cancel="videoBatch.cancelDelete"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmVisible"
|
||||
eyebrow="删除确认"
|
||||
@@ -305,6 +337,8 @@ import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
||||
@@ -315,6 +349,7 @@ import {
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
isVideoPickCancelled,
|
||||
@@ -372,6 +407,19 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const videoBatch = useBatchDeletion({
|
||||
items: videos,
|
||||
deleteOne: (video) =>
|
||||
familyMediaApi.deleteVideo(genealogyId.value, video.id, {
|
||||
requestController: videoDeletionRequestController,
|
||||
}),
|
||||
resourceName: "视频",
|
||||
isActive: () => pageActive,
|
||||
onEmpty: () => {
|
||||
videoListState.value = "ready";
|
||||
},
|
||||
});
|
||||
const deletableVideos = videoBatch.deletableItems;
|
||||
const videoCommentPlaceholder = computed(() =>
|
||||
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
|
||||
);
|
||||
@@ -599,6 +647,7 @@ const loadVideos = async () => {
|
||||
});
|
||||
if (!pageActive) return;
|
||||
videos.value = videoRows;
|
||||
videoBatch.exitSelectionMode();
|
||||
videoListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
@@ -822,7 +871,7 @@ const submitVideo = async () => {
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
? returnTo("G05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const returnToVideoList = () => {
|
||||
pageState.value = "list";
|
||||
@@ -833,6 +882,15 @@ const returnToVideoList = () => {
|
||||
return true;
|
||||
};
|
||||
const requestBack = async () => {
|
||||
if (videoBatch.deleting.value) return true;
|
||||
if (videoBatch.confirmationVisible.value) {
|
||||
videoBatch.cancelDelete();
|
||||
return true;
|
||||
}
|
||||
if (videoBatch.selectionMode.value) {
|
||||
videoBatch.exitSelectionMode();
|
||||
return true;
|
||||
}
|
||||
if (verticalViewerVisible.value) {
|
||||
closeVerticalViewer();
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="page-shell genealogy-index"
|
||||
:class="{ 'genealogy-index--split': isListLayout }"
|
||||
>
|
||||
<GenealogyPageBackground />
|
||||
<GenealogyPageBackground tone="light" />
|
||||
<PageHeader
|
||||
root
|
||||
notice
|
||||
@@ -56,69 +56,6 @@
|
||||
</view>
|
||||
|
||||
<template v-else-if="hasGenealogies">
|
||||
<view class="genealogy-fixed-zone">
|
||||
<view class="current-slip" @click="openSwitcher">
|
||||
<view class="current-summary">
|
||||
<view class="current-seal">
|
||||
<text class="current-seal-title">家谱</text>
|
||||
</view>
|
||||
<text class="current-name">{{ currentGenealogy.name }}</text>
|
||||
<text class="current-switch-copy">切换</text>
|
||||
</view>
|
||||
<view class="current-info-divider"></view>
|
||||
<view class="current-meta">
|
||||
<view class="current-meta-item current-meta-item--location">
|
||||
<image
|
||||
class="current-meta-icon"
|
||||
src="/static/assets/foundation/transparent/meta-location.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="current-meta-item-text">{{
|
||||
currentGenealogy.location
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="current-meta-item current-meta-item--members">
|
||||
<image
|
||||
class="current-meta-icon"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="current-meta-item-text"
|
||||
>{{ currentGenealogy.memberCount }} 位成员</text
|
||||
>
|
||||
</view>
|
||||
<view class="current-meta-item current-meta-item--role">
|
||||
<image
|
||||
class="current-meta-icon"
|
||||
src="/static/assets/foundation/transparent/meta-admin.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="current-meta-item-text">{{
|
||||
currentRoleLabel
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="shortcut-grid">
|
||||
<view
|
||||
v-for="shortcut in visibleShortcuts"
|
||||
:key="shortcut.key"
|
||||
class="shortcut-item"
|
||||
@click="openShortcut(shortcut.key)"
|
||||
>
|
||||
<image class="shortcut-icon" :src="shortcut.icon" mode="aspectFit" />
|
||||
<text class="shortcut-label">{{ shortcut.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<image
|
||||
class="section-divider"
|
||||
src="/static/assets/modules/genealogy/transparent/section-divider.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="genealogy-list-scroll"
|
||||
scroll-y
|
||||
@@ -126,8 +63,13 @@
|
||||
@scroll="handleListScroll"
|
||||
>
|
||||
<view class="genealogy-lower">
|
||||
<view class="genealogy-list-toolbar">
|
||||
<view class="section-heading genealogy-list-heading">
|
||||
<text>{{ managedGenealogies.length ? "我管理的" : "我加入的" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="managedGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我管理的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="genealogy in managedGenealogies"
|
||||
:key="genealogy.id"
|
||||
@@ -139,7 +81,7 @@
|
||||
</view>
|
||||
|
||||
<view v-if="memberGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我加入的</text></view>
|
||||
<view v-if="managedGenealogies.length" class="section-heading"><text>我加入的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="genealogy in memberGenealogies"
|
||||
:key="genealogy.id"
|
||||
@@ -171,85 +113,33 @@
|
||||
/>
|
||||
</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">
|
||||
<view class="create-action" hover-class="action-hover" @click="openAddDialog">
|
||||
<image
|
||||
class="create-cloud"
|
||||
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<image
|
||||
class="create-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/add.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<text>添加家谱</text>
|
||||
<image
|
||||
class="create-cloud create-cloud--right"
|
||||
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
<AppPromotionStrip placement="home_bottom" title="家谱服务推荐" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
<HomeAdvertisementPanel
|
||||
class="home-content-fixed"
|
||||
@open="openContentHome"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<view v-else class="genealogy-empty-state">
|
||||
@@ -284,12 +174,6 @@
|
||||
>
|
||||
<text class="empty-create-action__copy">创建家谱</text>
|
||||
</view>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="观看宣传视频"
|
||||
@click="openPlatformVideos"
|
||||
/>
|
||||
<text class="empty-create-note"
|
||||
>确认没有现有家谱后再创建,避免重复建谱</text
|
||||
>
|
||||
@@ -322,7 +206,7 @@
|
||||
@saved="applySavedGenealogyOrder"
|
||||
/>
|
||||
|
||||
<AppTabbar active="genealogy" />
|
||||
<AppTabbar active="genealogy" tone="light" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -332,9 +216,9 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
|
||||
import GenealogyCard from "@/components/genealogy/Card.vue";
|
||||
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
|
||||
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
|
||||
@@ -345,12 +229,11 @@ import {
|
||||
} 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 {
|
||||
consumeNavigationResult,
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
@@ -372,18 +255,14 @@ 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;
|
||||
|
||||
@@ -485,37 +364,11 @@ 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(() => {
|
||||
@@ -527,7 +380,6 @@ onShow(() => {
|
||||
) {
|
||||
requestedGenealogyId.value = navigationResult.entityId;
|
||||
loadGenealogies();
|
||||
loadFeaturedVideos();
|
||||
return;
|
||||
}
|
||||
if (skipNextShowRefresh) {
|
||||
@@ -537,7 +389,6 @@ onShow(() => {
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
loadFeaturedVideos();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
@@ -545,11 +396,9 @@ onUnload(() => {
|
||||
genealogyLoadGeneration += 1;
|
||||
unreadLoadGeneration += 1;
|
||||
quotaLoadGeneration += 1;
|
||||
featuredVideoLoadGeneration += 1;
|
||||
genealogyListRequestController.abort();
|
||||
unreadRequestController.abort();
|
||||
quotaRequestController.abort();
|
||||
featuredVideoRequestController.abort();
|
||||
});
|
||||
|
||||
const hasGenealogies = computed(() => genealogies.value.length > 0);
|
||||
@@ -572,40 +421,6 @@ const currentGenealogy = computed(
|
||||
(genealogy) => genealogy.id === selectedGenealogyId.value,
|
||||
) || null,
|
||||
);
|
||||
const canManageCurrentGenealogy = computed(
|
||||
() => currentGenealogy.value?.canManage === true,
|
||||
);
|
||||
const currentRoleLabel = computed(() =>
|
||||
canManageCurrentGenealogy.value ? "管理员" : "成员",
|
||||
);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
key: "tree",
|
||||
label: "世系图",
|
||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-tree.png",
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: "成员",
|
||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-members.png",
|
||||
},
|
||||
{
|
||||
key: "poem",
|
||||
label: "字辈诗",
|
||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-generation-poem.png",
|
||||
},
|
||||
{
|
||||
key: "applications",
|
||||
label: "申请审核",
|
||||
icon: "/static/assets/modules/genealogy/transparent/shortcut-application.png",
|
||||
},
|
||||
];
|
||||
const visibleShortcuts = computed(() =>
|
||||
canManageCurrentGenealogy.value
|
||||
? shortcuts
|
||||
: shortcuts.filter((shortcut) => shortcut.key !== "applications"),
|
||||
);
|
||||
|
||||
const openGenealogy = (genealogy) =>
|
||||
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
|
||||
@@ -644,21 +459,7 @@ 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 openContentHome = () => goRoot("F01");
|
||||
const openOrderDialog = () => {
|
||||
if (genealogies.value.length < 2) return;
|
||||
orderDialogVisible.value = true;
|
||||
@@ -714,17 +515,6 @@ const retryGenealogyLoad = () => {
|
||||
loadGenealogies();
|
||||
};
|
||||
|
||||
const openShortcut = (shortcutKey) => {
|
||||
if (!currentGenealogy.value) return;
|
||||
const genealogyId = String(currentGenealogy.value.id);
|
||||
const actions = {
|
||||
tree: () => openPage("T01", { genealogyId }, "G01"),
|
||||
members: () => openPage("G13", { genealogyId }, "G01"),
|
||||
poem: () => openPage("G12", { genealogyId }, "G01"),
|
||||
applications: () => openPage("G10", { genealogyId }, "G01"),
|
||||
};
|
||||
return actions[shortcutKey]?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -735,12 +525,12 @@ const openShortcut = (shortcutKey) => {
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #f9f6ef;
|
||||
background: #faf8f3;
|
||||
}
|
||||
|
||||
.genealogy-content {
|
||||
z-index: 1;
|
||||
padding: 24rpx 32rpx calc(176rpx + env(safe-area-inset-bottom));
|
||||
padding: 0 32rpx calc(176rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.genealogy-index--split {
|
||||
@@ -760,10 +550,6 @@ const openShortcut = (shortcutKey) => {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.genealogy-fixed-zone {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.genealogy-list-scroll {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
@@ -771,151 +557,25 @@ const openShortcut = (shortcutKey) => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.current-slip {
|
||||
@include adaptive-genealogy-current-slip;
|
||||
display: block;
|
||||
min-height: 300rpx;
|
||||
padding: 30rpx 34rpx 28rpx;
|
||||
.genealogy-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 70rpx;
|
||||
padding: 0 4rpx 2rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.current-seal {
|
||||
display: flex;
|
||||
width: 80rpx;
|
||||
height: 128rpx;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png")
|
||||
center / contain no-repeat;
|
||||
color: #fff7e7;
|
||||
}
|
||||
.current-seal-title {
|
||||
color: #fff7e7;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
.current-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.current-name {
|
||||
min-width: 0;
|
||||
.genealogy-list-heading {
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(18px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.current-switch-copy {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 16rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.34);
|
||||
border-radius: 999rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.current-info-divider {
|
||||
width: 100%;
|
||||
height: 1rpx;
|
||||
margin: 18rpx 0 16rpx;
|
||||
background: rgba(181, 138, 75, 0.48);
|
||||
}
|
||||
|
||||
.current-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 28rpx;
|
||||
row-gap: 10rpx;
|
||||
margin-top: 0;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.current-meta-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
|
||||
.current-meta-item-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.current-meta-item--location {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.current-meta-item--role {
|
||||
justify-self: end;
|
||||
}
|
||||
.current-meta-icon {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8rpx;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shortcut-grid {
|
||||
display: flex;
|
||||
min-height: 168rpx;
|
||||
align-items: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.shortcut-icon {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
}
|
||||
.shortcut-label {
|
||||
width: 100%;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 27rpx, 19px);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30rpx;
|
||||
margin: 4rpx 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.genealogy-lower {
|
||||
padding: 12rpx 0 36rpx;
|
||||
padding: 0 0 24rpx;
|
||||
}
|
||||
|
||||
.list-section {
|
||||
margin-top: 16rpx;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
@@ -951,7 +611,7 @@ const openShortcut = (shortcutKey) => {
|
||||
padding: 24rpx 28rpx;
|
||||
border: 1rpx solid rgba(149, 103, 49, 0.24);
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 250, 240, 0.72);
|
||||
background: rgba(255, 253, 249, 0.84);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -978,164 +638,17 @@ 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;
|
||||
width: 350rpx;
|
||||
min-height: 96rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
}
|
||||
|
||||
.create-action {
|
||||
width: 350rpx;
|
||||
margin: 32rpx auto 0;
|
||||
margin: 8rpx auto 24rpx;
|
||||
border: 2rpx solid $brand-red;
|
||||
border-radius: 48rpx;
|
||||
background: rgba(255, 249, 238, 0.66);
|
||||
background: rgba(255, 253, 249, 0.8);
|
||||
box-sizing: border-box;
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
@@ -1144,28 +657,31 @@ const openShortcut = (shortcutKey) => {
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
.create-action .create-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
margin: 0 8rpx;
|
||||
}
|
||||
|
||||
.create-cloud {
|
||||
width: 54rpx;
|
||||
height: 28rpx;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.create-action > text {
|
||||
flex: 0 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.create-cloud--right {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.home-content-fixed {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: flex;
|
||||
min-height: 540rpx;
|
||||
@@ -1382,16 +898,6 @@ const openShortcut = (shortcutKey) => {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.current-name {
|
||||
font-size: clamp(20px, 40rpx, 26px);
|
||||
}
|
||||
.shortcut-label {
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.current-meta-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
.create-action {
|
||||
width: 320rpx;
|
||||
}
|
||||
|
||||
+118
-23
@@ -198,15 +198,26 @@
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="overview-family" @click="toFamily">
|
||||
<view>
|
||||
<text>家族近况</text>
|
||||
<text>进入家族圈查看动态</text>
|
||||
<view class="overview-family-content">
|
||||
<view class="overview-family-content__heading">
|
||||
<text>家族内容</text>
|
||||
<text>按用途进入动态、影像与家族事务</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="group in familyContentGroups"
|
||||
:key="group.key"
|
||||
class="overview-family-group"
|
||||
>
|
||||
<text class="overview-family-group__title">{{ group.title }}</text>
|
||||
<view class="overview-family-grid">
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
class="overview-family-link"
|
||||
@click="openFamilySection(item.key)"
|
||||
>{{ item.label }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-note">
|
||||
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
|
||||
@@ -280,7 +291,6 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
goBack,
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
@@ -378,7 +388,53 @@ onUnload(() => {
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||
const toGenealogies = () => returnTo("G01", {});
|
||||
const toTree = () => openPage("T01", { genealogyId: genealogyId.value }, "G05");
|
||||
const toFamily = () => goRoot("F01", { genealogyId: genealogyId.value });
|
||||
const familyContentGroups = [
|
||||
{
|
||||
key: "memories",
|
||||
title: "家族记录",
|
||||
items: [
|
||||
{ key: "feed", label: "家族动态" },
|
||||
{ key: "articles", label: "谱文" },
|
||||
{ key: "albums", label: "相册" },
|
||||
{ key: "videos", label: "家族视频" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "affairs",
|
||||
title: "家族事务",
|
||||
items: [
|
||||
{ key: "rituals", label: "礼仪" },
|
||||
{ key: "memos", label: "备忘" },
|
||||
{ key: "benefactors", label: "家族恩人" },
|
||||
{ key: "people", label: "人物录" },
|
||||
{ key: "gifts", label: "贺礼簿" },
|
||||
{ key: "merits", label: "功德录" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const openFamilySection = (key) => {
|
||||
if (!genealogyId.value) return;
|
||||
if (key === "feed") return openPage("F12", { genealogyId: genealogyId.value }, "G05");
|
||||
const routes = {
|
||||
articles: "F04",
|
||||
albums: "F07",
|
||||
rituals: "R05",
|
||||
memos: "R10",
|
||||
benefactors: "R10",
|
||||
people: "R01",
|
||||
gifts: "R03",
|
||||
merits: "R11",
|
||||
videos: "F10",
|
||||
};
|
||||
return openPage(
|
||||
routes[key],
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
|
||||
},
|
||||
"G05",
|
||||
);
|
||||
};
|
||||
const toApplications = () =>
|
||||
openPage("G10", { genealogyId: genealogyId.value }, "G05");
|
||||
const toSettings = () =>
|
||||
@@ -573,8 +629,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
.overview-summary text {
|
||||
display: block;
|
||||
}
|
||||
.overview-action__chevron,
|
||||
.overview-family image {
|
||||
.overview-action__chevron {
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
opacity: 0.68;
|
||||
@@ -593,27 +648,67 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
.overview-family {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 26rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
.overview-family-content {
|
||||
margin: 14rpx 42rpx 0;
|
||||
padding: 20rpx 0;
|
||||
padding: 24rpx 0 0;
|
||||
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
|
||||
}
|
||||
|
||||
.overview-family-content__heading text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overview-family-content__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(18px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overview-family-content__heading text:last-child {
|
||||
margin-top: 5rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.overview-family text {
|
||||
display: block;
|
||||
|
||||
.overview-family-group {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.overview-family text:first-child {
|
||||
margin-bottom: 3rpx;
|
||||
|
||||
.overview-family-group__title {
|
||||
display: block;
|
||||
color: #7f4f16;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overview-family-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.overview-family-link {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 8rpx 10rpx;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(247, 237, 218, 0.76);
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.overview-family-link::after {
|
||||
border: 0;
|
||||
}
|
||||
.overview-note {
|
||||
display: flex;
|
||||
|
||||
+60
-188
@@ -79,36 +79,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-metadata" aria-label="个人资料摘要">
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/icon-person.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view><text>性别</text><text class="app-single-line">{{ sexText }}</text></view>
|
||||
</view>
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/icon-calendar.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view><text>生日</text><text class="app-single-line">{{ birthdayText }}</text></view>
|
||||
</view>
|
||||
<view class="profile-metadata__item profile-metadata__item--email">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/icon-envelope.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view><text>邮箱</text><text class="app-long-value">{{ profile.email || "未设置" }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-preference" aria-label="个性化推荐设置">
|
||||
<view>
|
||||
<text>个性化推荐</text>
|
||||
@@ -165,9 +135,12 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<HomeAdvertisementPanel
|
||||
class="profile-home-advertisements"
|
||||
@open="openContentHome"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -177,18 +150,16 @@ import { computed, reactive, ref } from "vue";
|
||||
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import {
|
||||
PROFILE_SEX_OPTIONS
|
||||
} from "@/services/api/profile-contract.js";
|
||||
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage } from "@/utils/navigation/gateway.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
|
||||
const profile = reactive({
|
||||
avatarFile: null,
|
||||
@@ -211,84 +182,46 @@ const preferenceSaving = ref(false);
|
||||
const preferenceError = ref("");
|
||||
let isPageActive = true;
|
||||
|
||||
const serviceGroups = [
|
||||
{
|
||||
title: "账户与服务",
|
||||
items: [
|
||||
{
|
||||
routeKey: "M03",
|
||||
label: "账号与安全",
|
||||
iconVariant: "security",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-security.png",
|
||||
},
|
||||
{
|
||||
routeKey: "N01",
|
||||
label: "消息中心",
|
||||
iconVariant: "notifications",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M11",
|
||||
label: "活动邀请",
|
||||
iconVariant: "invitations",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M09",
|
||||
label: "VIP 服务",
|
||||
iconVariant: "vip",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M12",
|
||||
label: "收益与提现",
|
||||
iconVariant: "earnings",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
const profileIcons = Object.freeze({
|
||||
security: "/static/assets/modules/profile/transparent/icon-security.png",
|
||||
notifications: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
vip: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
help: "/static/assets/modules/profile/transparent/icon-help.png",
|
||||
feedback: "/static/assets/modules/profile/transparent/icon-feedback.png",
|
||||
settings: "/static/assets/modules/profile/transparent/icon-settings.png",
|
||||
});
|
||||
|
||||
const serviceItem = (routeKey, label, iconVariant, params = null) => ({
|
||||
routeKey,
|
||||
label,
|
||||
iconVariant,
|
||||
icon: profileIcons[iconVariant],
|
||||
...(params ? { params } : {}),
|
||||
});
|
||||
|
||||
const serviceGroups = Object.freeze([
|
||||
{
|
||||
title: "支持与其他",
|
||||
items: [
|
||||
serviceItem("M06", "帮助中心", "help"),
|
||||
serviceItem("M07", "意见反馈", "feedback"),
|
||||
serviceItem("M03", "账号与安全", "security"),
|
||||
serviceItem("M10", "关于与设置", "settings"),
|
||||
serviceItem("F13", "网站公告", "notifications", {
|
||||
title: "网站公告",
|
||||
articleType: "notice",
|
||||
}),
|
||||
serviceItem("F13", "家谱新闻列表(图文)", "feedback", {
|
||||
title: "家谱新闻",
|
||||
articleType: "news",
|
||||
}),
|
||||
{
|
||||
routeKey: "M06",
|
||||
label: "帮助中心",
|
||||
iconVariant: "help",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-help.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M07",
|
||||
label: "意见反馈",
|
||||
iconVariant: "feedback",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-feedback.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M08",
|
||||
label: "应用推广",
|
||||
iconVariant: "promotion",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-promotion.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M10",
|
||||
label: "关于与设置",
|
||||
iconVariant: "settings",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-settings.png",
|
||||
...serviceItem("F04", "家谱系列文章(图文)", "feedback"),
|
||||
requiresGenealogy: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const profileSexLabels = Object.freeze(
|
||||
Object.fromEntries(PROFILE_SEX_OPTIONS.map(({ value, label }) => [value, label])),
|
||||
);
|
||||
const sexText = computed(
|
||||
() => profileSexLabels[String(profile.sex)] || "未设置",
|
||||
);
|
||||
const birthdayText = computed(() =>
|
||||
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
|
||||
? profile.birthday.slice(0, 10)
|
||||
: "未设置",
|
||||
);
|
||||
]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (loading.value) return;
|
||||
@@ -341,9 +274,21 @@ const changeRecommendationPreference = async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openService = (item) => openPage(item.routeKey, {}, "M01");
|
||||
const openService = (item) => {
|
||||
const params = { ...(item.params || {}) };
|
||||
if (item.requiresGenealogy) {
|
||||
const genealogyId = genealogyContext.getCurrentGenealogyId();
|
||||
if (!genealogyId) {
|
||||
uni.showToast({ title: "请先在家谱页选择家谱", icon: "none" });
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
params.genealogyId = genealogyId;
|
||||
}
|
||||
return openPage(item.routeKey, params, "M01");
|
||||
};
|
||||
const openEdit = () => openPage("M02", {}, "M01");
|
||||
const openSettings = () => openPage("M10", {}, "M01");
|
||||
const openContentHome = () => goRoot("F01");
|
||||
|
||||
onShow(() => {
|
||||
void loadProfile();
|
||||
@@ -352,13 +297,14 @@ onShow(() => {
|
||||
onPageScroll(({ scrollTop = 0 }) => {
|
||||
compactHeader.value = scrollTop > 44;
|
||||
});
|
||||
onUnload(() => {
|
||||
const disposeProfilePortal = () => {
|
||||
isPageActive = false;
|
||||
compactHeader.value = false;
|
||||
profileReadController.abort();
|
||||
preferenceReadController.abort();
|
||||
preferenceSaveController.abort();
|
||||
});
|
||||
};
|
||||
onUnload(disposeProfilePortal);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -611,68 +557,6 @@ onUnload(() => {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.profile-metadata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
z-index: 1;
|
||||
margin: -36rpx 34rpx 0;
|
||||
padding: 10rpx 12rpx 12rpx;
|
||||
border: 1rpx solid rgba(117, 83, 52, 0.09);
|
||||
border-radius: 18rpx;
|
||||
background: rgba(239, 234, 224, 0.92);
|
||||
}
|
||||
|
||||
.profile-metadata__item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
padding: 14rpx 12rpx;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.profile-metadata__item:first-child {
|
||||
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
|
||||
}
|
||||
|
||||
.profile-metadata__item--email {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
|
||||
}
|
||||
|
||||
.profile-metadata__icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
flex: 0 0 auto;
|
||||
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
|
||||
}
|
||||
|
||||
.profile-metadata__item view {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-metadata__item text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-metadata__item text:first-child {
|
||||
color: #857263;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
|
||||
.profile-metadata__item text:last-child {
|
||||
margin-top: 4rpx;
|
||||
color: #382c25;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-metadata__item--email text:last-child {
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profile-preference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -717,6 +601,10 @@ onUnload(() => {
|
||||
margin: 49rpx 58rpx 0 48rpx;
|
||||
}
|
||||
|
||||
.profile-home-advertisements {
|
||||
margin: 28rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.profile-service-group + .profile-service-group {
|
||||
margin-top: 51rpx;
|
||||
}
|
||||
@@ -825,22 +713,6 @@ onUnload(() => {
|
||||
height: 176rpx;
|
||||
}
|
||||
|
||||
.profile-metadata {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.profile-metadata__item:first-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.profile-metadata__item + .profile-metadata__item {
|
||||
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
|
||||
}
|
||||
|
||||
.profile-metadata__item--email {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.profile-services {
|
||||
margin-right: 38rpx;
|
||||
margin-left: 38rpx;
|
||||
|
||||
@@ -23,9 +23,15 @@
|
||||
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
|
||||
</view>
|
||||
<view v-else-if="referralProfile.enabled" class="referral-card__content">
|
||||
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
|
||||
<text>已成功邀请 {{ referralProfile.referredUserCount }} 人</text>
|
||||
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
|
||||
<view class="referral-card__details">
|
||||
<ReferralQrCode :value="referralProfile.shareUrl" />
|
||||
<view class="referral-card__copy">
|
||||
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
|
||||
<text>已成功邀请 {{ referralProfile.referredUserCount }} 人</text>
|
||||
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
|
||||
<text class="referral-card__qr-hint">请家人扫码打开注册链接</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="referral-card__actions">
|
||||
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
|
||||
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
|
||||
@@ -83,6 +89,7 @@ import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import ReferralQrCode from "@/components/ReferralQrCode.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -318,7 +325,12 @@ onUnload(() => {
|
||||
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||||
.referral-card__content,
|
||||
.referral-card__state { margin-top: 22rpx; }
|
||||
.referral-card__details { display: flex; align-items: center; gap: 24rpx; }
|
||||
.referral-card__copy { min-width: 0; flex: 1; }
|
||||
.referral-card__copy > text { display: block; }
|
||||
.referral-card__copy > text:not(:first-child) { margin-top: 8rpx; }
|
||||
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
|
||||
.referral-card__qr-hint { color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
|
||||
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
|
||||
.promotion-state-card .app-button { margin-top: 28rpx; }
|
||||
@@ -353,5 +365,10 @@ onUnload(() => {
|
||||
min-width: 0;
|
||||
flex: 1 1 220rpx;
|
||||
}
|
||||
|
||||
.referral-card__details {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,12 +30,32 @@
|
||||
><AppButton block label="新建礼仪活动" @click="createCeremony"
|
||||
/></view>
|
||||
<view v-else class="ceremony-list"
|
||||
><view
|
||||
><BatchManagementBar
|
||||
v-if="deletableCeremonies.length"
|
||||
resource-name="礼仪活动"
|
||||
:active="ceremonyBatch.selectionMode.value"
|
||||
:selected-count="ceremonyBatch.selectedCount.value"
|
||||
:all-selected="ceremonyBatch.allSelected.value"
|
||||
:busy="ceremonyBatch.deleting.value"
|
||||
@start="ceremonyBatch.enterSelectionMode"
|
||||
@finish="ceremonyBatch.exitSelectionMode"
|
||||
@toggle-all="ceremonyBatch.toggleAll"
|
||||
@delete="ceremonyBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="ceremonyBatch.notice.value" class="batch-notice" role="status">{{ ceremonyBatch.notice.value }}</text>
|
||||
<text v-if="ceremonyBatch.error.value" class="batch-error" role="alert">{{ ceremonyBatch.error.value }}</text>
|
||||
<view
|
||||
v-for="item in ceremonies"
|
||||
:key="item.id"
|
||||
class="ceremony-card"
|
||||
@click="openCeremony(item)"
|
||||
><image
|
||||
@click="handleCeremonyClick(item)"
|
||||
><BatchSelectionMark
|
||||
v-if="ceremonyBatch.selectionMode.value && item.canDelete"
|
||||
:selected="ceremonyBatch.isSelected(item)"
|
||||
:label="`礼仪活动:${item.title}`"
|
||||
@toggle="ceremonyBatch.toggleSelection(item)"
|
||||
/>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="ceremony-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
@@ -50,6 +70,18 @@
|
||||
></view
|
||||
>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="ceremonyBatch.confirmationVisible.value"
|
||||
:close-on-mask="false"
|
||||
eyebrow="批量删除"
|
||||
title="将选中的礼仪活动移入回收站?"
|
||||
:message="ceremonyBatch.confirmationMessage.value"
|
||||
:confirm-text="ceremonyBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
@confirm="ceremonyBatch.confirmDelete"
|
||||
@cancel="ceremonyBatch.cancelDelete"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -57,7 +89,10 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { 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";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
@@ -65,6 +100,7 @@ import {
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
@@ -72,8 +108,22 @@ const genealogyId = ref("");
|
||||
const ceremonies = ref([]);
|
||||
const ceremonyListState = ref("loading");
|
||||
const ceremonyListRequestController = createRequestController();
|
||||
const ceremonyDeleteRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const ceremonyBatch = useBatchDeletion({
|
||||
items: ceremonies,
|
||||
deleteOne: (ceremony) =>
|
||||
ceremonyApi.deleteCeremony(genealogyId.value, ceremony.id, {
|
||||
requestController: ceremonyDeleteRequestController,
|
||||
}),
|
||||
resourceName: "礼仪活动",
|
||||
isActive: () => pageActive,
|
||||
onEmpty: () => {
|
||||
ceremonyListState.value = "empty";
|
||||
},
|
||||
});
|
||||
const deletableCeremonies = ceremonyBatch.deletableItems;
|
||||
const loadCeremonies = async () => {
|
||||
if (!valid.value) return;
|
||||
ceremonyListRequestController.abort();
|
||||
@@ -84,6 +134,7 @@ const loadCeremonies = async () => {
|
||||
});
|
||||
if (!pageActive) return;
|
||||
ceremonies.value = rows;
|
||||
ceremonyBatch.exitSelectionMode();
|
||||
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
@@ -91,7 +142,7 @@ const loadCeremonies = async () => {
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createCeremony = () =>
|
||||
valid.value
|
||||
? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05")
|
||||
@@ -102,6 +153,13 @@ const openCeremony = (item) =>
|
||||
{ genealogyId: genealogyId.value, ceremonyId: item.id },
|
||||
"R05",
|
||||
);
|
||||
const handleCeremonyClick = (item) => {
|
||||
if (ceremonyBatch.selectionMode.value && item?.canDelete) {
|
||||
ceremonyBatch.toggleSelection(item);
|
||||
return;
|
||||
}
|
||||
openCeremony(item);
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadCeremonies();
|
||||
@@ -112,6 +170,7 @@ onShow(() => {
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
ceremonyListRequestController.abort();
|
||||
ceremonyDeleteRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -156,6 +215,14 @@ onUnload(() => {
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.batch-notice,
|
||||
.batch-error {
|
||||
display: block;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.batch-notice { color: #426538; }
|
||||
.batch-error { color: $brand-red; }
|
||||
.ceremony-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
|
||||
@@ -134,6 +134,24 @@
|
||||
</view>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view v-if="!isEdit" class="field field--password">
|
||||
<text>内容密码(选填)</text>
|
||||
<text class="person-option-note">填写后会先创建记录,再立即启用密码保护;设置失败时会保留记录并明确提示。</text>
|
||||
<input
|
||||
v-model="contentPassword"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="请输入8至128位内容密码"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<input
|
||||
v-model="contentPasswordConfirm"
|
||||
password
|
||||
maxlength="128"
|
||||
placeholder="请再次输入内容密码"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
@@ -167,9 +185,21 @@
|
||||
><AppButton block label="新建成长记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>成长记录已保存。</text
|
||||
>
|
||||
<BatchManagementBar
|
||||
v-if="deletableGrowthRecords.length"
|
||||
resource-name="成长记录"
|
||||
:active="growthBatch.selectionMode.value"
|
||||
:selected-count="growthBatch.selectedCount.value"
|
||||
:all-selected="growthBatch.allSelected.value"
|
||||
:busy="growthBatch.deleting.value"
|
||||
@start="growthBatch.enterSelectionMode"
|
||||
@finish="growthBatch.exitSelectionMode"
|
||||
@toggle-all="growthBatch.toggleAll"
|
||||
@delete="growthBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="growthBatch.notice.value" class="save-notice" role="status">{{ growthBatch.notice.value }}</text>
|
||||
<text v-if="growthBatch.error.value" class="delete-error" role="alert">{{ growthBatch.error.value }}</text>
|
||||
<text v-if="saveNotice" class="save-notice">{{ saveNotice }}</text>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view class="record-filters">
|
||||
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
|
||||
@@ -185,9 +215,15 @@
|
||||
:key="item.id"
|
||||
class="record-card"
|
||||
role="button"
|
||||
:aria-label="`查看${item.title}详情`"
|
||||
@click="openRecordDetail(item)"
|
||||
:aria-label="growthBatch.selectionMode.value && item.canDelete ? `${growthBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`"
|
||||
@click="handleGrowthRecordClick(item)"
|
||||
>
|
||||
<BatchSelectionMark
|
||||
v-if="growthBatch.selectionMode.value && item.canDelete"
|
||||
:selected="growthBatch.isSelected(item)"
|
||||
:label="`成长记录:${item.title}`"
|
||||
@toggle="growthBatch.toggleSelection(item)"
|
||||
/>
|
||||
<view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
@@ -197,14 +233,14 @@
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<AppButton
|
||||
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
|
||||
v-if="!growthBatch.selectionMode.value && item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEdit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
v-if="!growthBatch.selectionMode.value && item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@@ -219,6 +255,17 @@
|
||||
@busy-change="detailBusy = $event"
|
||||
@transient-change="detailTransientOpen = $event"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="growthBatch.confirmationVisible.value"
|
||||
title="将选中的成长记录移入回收站?"
|
||||
:message="growthBatch.confirmationMessage.value"
|
||||
:confirm-text="growthBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="growthBatch.confirmDelete"
|
||||
@cancel="growthBatch.cancelDelete"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃成长记录?"
|
||||
@@ -250,6 +297,8 @@ 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";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
@@ -261,6 +310,7 @@ import { businessDictionaryApi } from "@/services/api/business-dictionary-servic
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
@@ -277,7 +327,7 @@ const personId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const records = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const saveNotice = ref("");
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
@@ -299,6 +349,8 @@ const personOptions = ref([{ value: "", label: "不关联人物" }]);
|
||||
const defaultLineagePersonId = ref("");
|
||||
const growthTypeOptionsState = ref("loading");
|
||||
const growthTypeOptions = ref([]);
|
||||
const contentPassword = ref("");
|
||||
const contentPasswordConfirm = ref("");
|
||||
const form = reactive({
|
||||
lineagePersonId: "",
|
||||
recordType: "",
|
||||
@@ -314,6 +366,7 @@ const recordListRequestController = createRequestController();
|
||||
const growthEditorDetailRequestController = createRequestController();
|
||||
const growthMediaUploadRequestController = createRequestController();
|
||||
const growthRecordSaveRequestController = createRequestController();
|
||||
const growthPasswordRequestController = createRequestController();
|
||||
const personOptionsController = createRequestController();
|
||||
const growthTypeRequestController = createRequestController();
|
||||
const growthRecordDeleteController = createRequestController();
|
||||
@@ -356,6 +409,20 @@ const filteredRecords = computed(() => records.value.filter((item) =>
|
||||
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
|
||||
(!filterType.value || item.type === filterType.value),
|
||||
));
|
||||
const growthBatch = useBatchDeletion({
|
||||
items: records,
|
||||
visibleItems: filteredRecords,
|
||||
deleteOne: (record) =>
|
||||
lifeRecordApi.deleteGrowthRecord(genealogyId.value, record.id, {
|
||||
requestController: growthRecordDeleteController,
|
||||
}),
|
||||
resourceName: "成长记录",
|
||||
isActive: () => pageActive,
|
||||
onEmpty: () => {
|
||||
listState.value = "empty";
|
||||
},
|
||||
});
|
||||
const deletableGrowthRecords = growthBatch.deletableItems;
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
@@ -376,7 +443,9 @@ const dirty = computed(() =>
|
||||
field !== "lineagePersonId" && String(value).trim(),
|
||||
) ||
|
||||
form.lineagePersonId !== defaultLineagePersonId.value ||
|
||||
mediaReceipts.value.length > 0,
|
||||
mediaReceipts.value.length > 0 ||
|
||||
contentPassword.value ||
|
||||
contentPasswordConfirm.value,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -400,6 +469,8 @@ const resetForm = () => {
|
||||
remindClock: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
contentPassword.value = "";
|
||||
contentPasswordConfirm.value = "";
|
||||
editingRecord.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
@@ -456,6 +527,7 @@ const loadRecords = async () => {
|
||||
});
|
||||
if (!pageActive) return;
|
||||
records.value = rows;
|
||||
growthBatch.exitSelectionMode();
|
||||
listState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
@@ -464,7 +536,7 @@ const loadRecords = async () => {
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
saveNotice.value = "";
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
@@ -563,15 +635,24 @@ const selectGrowthType = (event) => {
|
||||
};
|
||||
const selectFilterPerson = (event) => {
|
||||
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
|
||||
growthBatch.exitSelectionMode();
|
||||
};
|
||||
const selectFilterType = (event) => {
|
||||
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
growthBatch.exitSelectionMode();
|
||||
};
|
||||
const openRecordDetail = (record) =>
|
||||
growthRecordDetailDialog.value?.open({
|
||||
...record,
|
||||
typeLabel: growthRecordTypeLabel(record),
|
||||
});
|
||||
const handleGrowthRecordClick = (record) => {
|
||||
if (growthBatch.selectionMode.value && record?.canDelete) {
|
||||
growthBatch.toggleSelection(record);
|
||||
return;
|
||||
}
|
||||
openRecordDetail(record);
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
@@ -595,6 +676,16 @@ const saveGrowthRecord = async () => {
|
||||
error.value = "请填写记录标题";
|
||||
return;
|
||||
}
|
||||
if (contentPassword.value || contentPasswordConfirm.value) {
|
||||
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
|
||||
error.value = "内容密码必须为8至128位。";
|
||||
return;
|
||||
}
|
||||
if (contentPassword.value !== contentPasswordConfirm.value) {
|
||||
error.value = "两次输入的内容密码不一致。";
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||
const payload = {
|
||||
...recordForm,
|
||||
@@ -619,6 +710,7 @@ const saveGrowthRecord = async () => {
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
let passwordSetupNotice = "";
|
||||
try {
|
||||
if (editingRecord.value) {
|
||||
await lifeRecordApi.updateGrowthRecord(
|
||||
@@ -628,14 +720,30 @@ const saveGrowthRecord = async () => {
|
||||
{ requestController: growthRecordSaveRequestController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
|
||||
const createdRecord = await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
|
||||
requestController: growthRecordSaveRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (contentPassword.value) {
|
||||
try {
|
||||
await lifeRecordApi.setGrowthRecordPassword(
|
||||
genealogyId.value,
|
||||
createdRecord.id,
|
||||
contentPassword.value,
|
||||
{ requestController: growthPasswordRequestController },
|
||||
);
|
||||
passwordSetupNotice = "成长记录已保存,并已启用内容密码。";
|
||||
} catch (passwordError) {
|
||||
if (!pageActive || isRequestCancelled(passwordError)) return;
|
||||
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
|
||||
passwordSetupNotice = `成长记录已经创建,但${failureCopy}。请打开详情重新设置;当前内容可能尚未受到密码保护。`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
saveNotice.value = passwordSetupNotice || "成长记录已保存。";
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
@@ -708,7 +816,13 @@ const deleteRecord = async () => {
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTransientOpen.value
|
||||
growthBatch.deleting.value
|
||||
? true
|
||||
: growthBatch.confirmationVisible.value
|
||||
? (growthBatch.cancelDelete(), true)
|
||||
: growthBatch.selectionMode.value
|
||||
? (growthBatch.exitSelectionMode(), true)
|
||||
: detailTransientOpen.value
|
||||
? (growthRecordDetailDialog.value?.closeTransient(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
@@ -745,6 +859,7 @@ onUnload(() => {
|
||||
growthEditorDetailRequestController.abort();
|
||||
growthMediaUploadRequestController.abort();
|
||||
growthRecordSaveRequestController.abort();
|
||||
growthPasswordRequestController.abort();
|
||||
personOptionsController.abort();
|
||||
growthTypeRequestController.abort();
|
||||
growthRecordDeleteController.abort();
|
||||
@@ -822,6 +937,9 @@ onUnload(() => {
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--password input + input {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
+106
-20
@@ -94,18 +94,46 @@
|
||||
><AppButton block :label="`新建${recordName}`" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="memo-list">
|
||||
<BatchManagementBar
|
||||
v-if="deletableMemos.length"
|
||||
:resource-name="recordName"
|
||||
:active="memoBatch.selectionMode.value"
|
||||
:selected-count="memoBatch.selectedCount.value"
|
||||
:all-selected="memoBatch.allSelected.value"
|
||||
:busy="memoBatch.deleting.value"
|
||||
@start="memoBatch.enterSelectionMode"
|
||||
@finish="memoBatch.exitSelectionMode"
|
||||
@toggle-all="memoBatch.toggleAll"
|
||||
@delete="memoBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="memoBatch.notice.value" class="save-notice" role="status">{{ memoBatch.notice.value }}</text>
|
||||
<text v-if="memoBatch.error.value" class="delete-error" role="alert">{{ memoBatch.error.value }}</text>
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>{{ recordName }}已保存。</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
|
||||
<view class="memo-card__copy">
|
||||
<text>{{ item.title }}</text>
|
||||
<text v-if="item.remindTime">{{ item.remindTime }}</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="memoBatch.selectionMode.value && item.canDelete ? `${memoBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMemoClick(item)">
|
||||
<BatchSelectionMark
|
||||
v-if="memoBatch.selectionMode.value && item.canDelete"
|
||||
:selected="memoBatch.isSelected(item)"
|
||||
:label="`${recordName}:${item.title}`"
|
||||
@toggle="memoBatch.toggleSelection(item)"
|
||||
/>
|
||||
<view class="memo-card__main">
|
||||
<image
|
||||
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||
class="memo-card__cover"
|
||||
:src="item.mediaFiles[0].accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="memo-card__copy">
|
||||
<text>{{ item.title }}</text>
|
||||
<text v-if="item.remindTime">{{ item.remindTime }}</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
|
||||
<view v-if="!memoBatch.selectionMode.value && (item.canEdit || item.canDelete)" class="memo-card__actions">
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
@@ -124,6 +152,17 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="memoBatch.confirmationVisible.value"
|
||||
:title="`将选中的${recordName}移入回收站?`"
|
||||
:message="memoBatch.confirmationMessage.value"
|
||||
:confirm-text="memoBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="memoBatch.confirmDelete"
|
||||
@cancel="memoBatch.cancelDelete"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
:eyebrow="`${recordName}详情`"
|
||||
@@ -178,8 +217,11 @@ 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";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -233,6 +275,19 @@ const memoDetailRequestController = createRequestController();
|
||||
const memoCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const memoBatch = useBatchDeletion({
|
||||
items: memos,
|
||||
deleteOne: (memo) =>
|
||||
lifeRecordApi.deleteMemo(genealogyId.value, memo.id, {
|
||||
requestController: memoDeletionRequestController,
|
||||
}),
|
||||
resourceName: "记录",
|
||||
isActive: () => pageActive,
|
||||
onEmpty: () => {
|
||||
listState.value = "empty";
|
||||
},
|
||||
});
|
||||
const deletableMemos = memoBatch.deletableItems;
|
||||
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
|
||||
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
|
||||
const pageTitle = computed(() => recordName.value);
|
||||
@@ -285,6 +340,7 @@ const loadMemos = async () => {
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
|
||||
memoBatch.exitSelectionMode();
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
if (pendingMemoId.value) {
|
||||
const target = memos.value.find((item) => item.id === pendingMemoId.value);
|
||||
@@ -372,6 +428,13 @@ const openMemoDetail = async (memo) => {
|
||||
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const handleMemoClick = (memo) => {
|
||||
if (memoBatch.selectionMode.value && memo?.canDelete) {
|
||||
memoBatch.toggleSelection(memo);
|
||||
return;
|
||||
}
|
||||
openMemoDetail(memo);
|
||||
};
|
||||
const closeMemoDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
@@ -503,19 +566,30 @@ const deleteMemo = async () => {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTarget.value
|
||||
? (closeMemoDetail(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
const requestBack = () => {
|
||||
if (memoBatch.deleting.value) return true;
|
||||
if (memoBatch.confirmationVisible.value) {
|
||||
memoBatch.cancelDelete();
|
||||
return true;
|
||||
}
|
||||
if (memoBatch.selectionMode.value) {
|
||||
memoBatch.exitSelectionMode();
|
||||
return true;
|
||||
}
|
||||
if (detailTarget.value) {
|
||||
closeMemoDetail();
|
||||
return true;
|
||||
}
|
||||
if (view.value !== "form") return goBack();
|
||||
return runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
@@ -708,10 +782,22 @@ onUnload(() => {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.memo-card__main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.memo-card__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.memo-card__cover {
|
||||
width: 148rpx;
|
||||
height: 148rpx;
|
||||
flex: 0 0 148rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(128, 89, 49, .12);
|
||||
}
|
||||
.memo-card__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
+110
-24
@@ -100,6 +100,20 @@
|
||||
><AppButton block label="新建功德记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="merit-list">
|
||||
<BatchManagementBar
|
||||
v-if="deletableMerits.length"
|
||||
resource-name="功德记录"
|
||||
:active="meritBatch.selectionMode.value"
|
||||
:selected-count="meritBatch.selectedCount.value"
|
||||
:all-selected="meritBatch.allSelected.value"
|
||||
:busy="meritBatch.deleting.value"
|
||||
@start="meritBatch.enterSelectionMode"
|
||||
@finish="meritBatch.exitSelectionMode"
|
||||
@toggle-all="meritBatch.toggleAll"
|
||||
@delete="meritBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="meritBatch.notice.value" class="save-notice" role="status">{{ meritBatch.notice.value }}</text>
|
||||
<text v-if="meritBatch.error.value" class="delete-error" role="alert">{{ meritBatch.error.value }}</text>
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>{{ saveNotice }}</text
|
||||
>
|
||||
@@ -107,15 +121,29 @@
|
||||
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMeritDetail(item)">
|
||||
<view>
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="meritBatch.selectionMode.value && item.canDelete ? `${meritBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMeritClick(item)">
|
||||
<BatchSelectionMark
|
||||
v-if="meritBatch.selectionMode.value && item.canDelete"
|
||||
:selected="meritBatch.isSelected(item)"
|
||||
:label="`功德记录:${item.title}`"
|
||||
@toggle="meritBatch.toggleSelection(item)"
|
||||
/>
|
||||
<view class="merit-card__main">
|
||||
<image
|
||||
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||
class="merit-card__cover"
|
||||
:src="item.mediaFiles[0].accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="merit-card__copy">
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="merit-card__amount">
|
||||
<view v-if="!meritBatch.selectionMode.value" class="merit-card__amount">
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
@@ -135,6 +163,17 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="meritBatch.confirmationVisible.value"
|
||||
title="将选中的功德记录移入回收站?"
|
||||
:message="meritBatch.confirmationMessage.value"
|
||||
:confirm-text="meritBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="meritBatch.confirmDelete"
|
||||
@cancel="meritBatch.cancelDelete"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="功德详情"
|
||||
@@ -199,9 +238,12 @@ 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";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -263,6 +305,19 @@ const meritTypeRequestController = createRequestController();
|
||||
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const meritBatch = useBatchDeletion({
|
||||
items: merits,
|
||||
deleteOne: (merit) =>
|
||||
lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
|
||||
requestController: meritDeletionRequestController,
|
||||
}),
|
||||
resourceName: "功德记录",
|
||||
isActive: () => pageActive,
|
||||
onEmpty: () => {
|
||||
listState.value = "empty";
|
||||
},
|
||||
});
|
||||
const deletableMerits = meritBatch.deletableItems;
|
||||
const isEdit = computed(() => Boolean(editingMerit.value));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
|
||||
@@ -338,6 +393,7 @@ const loadMerits = async () => {
|
||||
});
|
||||
if (!pageActive) return;
|
||||
merits.value = rows;
|
||||
meritBatch.exitSelectionMode();
|
||||
listState.value = merits.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
@@ -437,6 +493,13 @@ const openMeritDetail = async (merit) => {
|
||||
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const handleMeritClick = (merit) => {
|
||||
if (meritBatch.selectionMode.value && merit?.canDelete) {
|
||||
meritBatch.toggleSelection(merit);
|
||||
return;
|
||||
}
|
||||
openMeritDetail(merit);
|
||||
};
|
||||
const closeMeritDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
@@ -591,19 +654,30 @@ const deleteMerit = async () => {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTarget.value
|
||||
? (closeMeritDetail(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
const requestBack = () => {
|
||||
if (meritBatch.deleting.value) return true;
|
||||
if (meritBatch.confirmationVisible.value) {
|
||||
meritBatch.cancelDelete();
|
||||
return true;
|
||||
}
|
||||
if (meritBatch.selectionMode.value) {
|
||||
meritBatch.exitSelectionMode();
|
||||
return true;
|
||||
}
|
||||
if (detailTarget.value) {
|
||||
closeMeritDetail();
|
||||
return true;
|
||||
}
|
||||
if (view.value !== "form") return goBack();
|
||||
return runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
@@ -782,21 +856,33 @@ onUnload(() => {
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.merit-card > view {
|
||||
.merit-card__main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.merit-card__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.merit-card__cover {
|
||||
width: 148rpx;
|
||||
height: 148rpx;
|
||||
flex: 0 0 148rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(128, 89, 49, .12);
|
||||
}
|
||||
.merit-card text {
|
||||
display: block;
|
||||
}
|
||||
.merit-card > view text:first-child {
|
||||
.merit-card__copy text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > view text:not(:first-child) {
|
||||
.merit-card__copy text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
|
||||
@@ -30,8 +30,28 @@
|
||||
><AppButton block label="新建往来记录" @click="createRelative"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<BatchManagementBar
|
||||
v-if="deletableRecords.length"
|
||||
resource-name="往来记录"
|
||||
:active="recordBatch.selectionMode.value"
|
||||
:selected-count="recordBatch.selectedCount.value"
|
||||
:all-selected="recordBatch.allSelected.value"
|
||||
:busy="recordBatch.deleting.value"
|
||||
@start="recordBatch.enterSelectionMode"
|
||||
@finish="recordBatch.exitSelectionMode"
|
||||
@toggle-all="recordBatch.toggleAll"
|
||||
@delete="recordBatch.requestDelete"
|
||||
/>
|
||||
<text v-if="recordBatch.notice.value" class="batch-notice" role="status">{{ recordBatch.notice.value }}</text>
|
||||
<text v-if="recordBatch.error.value" class="delete-error" role="alert">{{ recordBatch.error.value }}</text>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="`查看${item.name}的往来详情`" @click="openRecordDetail(item)">
|
||||
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="recordBatch.selectionMode.value && item.canDelete ? `${recordBatch.isSelected(item) ? '取消选择' : '选择'}${item.name}` : `查看${item.name}的往来详情`" @click="handleRecordClick(item)">
|
||||
<BatchSelectionMark
|
||||
v-if="recordBatch.selectionMode.value && item.canDelete"
|
||||
:selected="recordBatch.isSelected(item)"
|
||||
:label="`往来记录:${item.name}`"
|
||||
@toggle="recordBatch.toggleSelection(item)"
|
||||
/>
|
||||
<image
|
||||
v-if="item.mediaFiles?.[0]?.accessUrl"
|
||||
class="record-card__cover"
|
||||
@@ -48,7 +68,7 @@
|
||||
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<view class="record-card__amount">
|
||||
<view v-if="!recordBatch.selectionMode.value" class="record-card__amount">
|
||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
@@ -68,6 +88,17 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="recordBatch.confirmationVisible.value"
|
||||
title="将选中的往来记录移入回收站?"
|
||||
:message="recordBatch.confirmationMessage.value"
|
||||
:confirm-text="recordBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||
cancel-text="继续选择"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="recordBatch.confirmDelete"
|
||||
@cancel="recordBatch.cancelDelete"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="往来详情"
|
||||
@@ -113,8 +144,11 @@ 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";
|
||||
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -139,6 +173,19 @@ const detailError = ref("");
|
||||
const relativeRecordDetailController = createRequestController();
|
||||
let isPageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const recordBatch = useBatchDeletion({
|
||||
items: records,
|
||||
deleteOne: (record) =>
|
||||
lifeRecordApi.deleteRelativeRecord(genealogyId.value, record.id, {
|
||||
requestController: relativeRecordDeleteController,
|
||||
}),
|
||||
resourceName: "往来记录",
|
||||
isActive: () => isPageActive,
|
||||
onEmpty: () => {
|
||||
relativeRecordListState.value = "empty";
|
||||
},
|
||||
});
|
||||
const deletableRecords = recordBatch.deletableItems;
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
relativeRecordListController.abort();
|
||||
@@ -149,6 +196,7 @@ const loadRecords = async () => {
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
records.value = rows;
|
||||
recordBatch.exitSelectionMode();
|
||||
relativeRecordListState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
@@ -156,7 +204,7 @@ const loadRecords = async () => {
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createRelative = () =>
|
||||
valid.value
|
||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||
@@ -188,6 +236,13 @@ const openRecordDetail = async (record) => {
|
||||
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const handleRecordClick = (record) => {
|
||||
if (recordBatch.selectionMode.value && record?.canDelete) {
|
||||
recordBatch.toggleSelection(record);
|
||||
return;
|
||||
}
|
||||
openRecordDetail(record);
|
||||
};
|
||||
const closeRecordDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
@@ -211,7 +266,15 @@ const closeDeleteConfirmation = () => {
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (deleting.value || detailState.value === "loading") return true;
|
||||
if (deleting.value || recordBatch.deleting.value || detailState.value === "loading") return true;
|
||||
if (recordBatch.confirmationVisible.value) {
|
||||
recordBatch.cancelDelete();
|
||||
return true;
|
||||
}
|
||||
if (recordBatch.selectionMode.value) {
|
||||
recordBatch.exitSelectionMode();
|
||||
return true;
|
||||
}
|
||||
if (deleteConfirmationVisible.value) {
|
||||
closeDeleteConfirmation();
|
||||
return true;
|
||||
|
||||
+166
-44
@@ -55,7 +55,7 @@
|
||||
<picker
|
||||
:range="sexOptions.map((item) => item.label)"
|
||||
:value="optionIndex(sexOptions, addForm.sex)"
|
||||
@change="selectOption('sex', sexOptions, $event)"
|
||||
@change="selectSex"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>性别</text
|
||||
@@ -168,19 +168,35 @@
|
||||
type="number"
|
||||
placeholder="可选正整数"
|
||||
placeholder-class="form-placeholder"
|
||||
@blur="loadRankOptions"
|
||||
/>
|
||||
<text v-else>首位成员固定为 1</text>
|
||||
</view>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="addForm.birthDate"
|
||||
@change="selectBirthDate"
|
||||
v-if="rankOptions.length"
|
||||
:range="rankPickerLabels"
|
||||
:value="rankOptionIndex"
|
||||
:disabled="rankOptionsState !== 'ready' || isSubmitting"
|
||||
@change="selectRank"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生日期</text
|
||||
><text>{{ addForm.birthDate || "请选择" }}</text>
|
||||
<text>排行称谓</text><text>{{ selectedRankName || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-else class="form-field form-field--disabled">
|
||||
<text>排行称谓</text><text>{{ rankOptionStateLabel }}</text>
|
||||
</view>
|
||||
<text v-if="rankOptionsState === 'error'" class="field-error"
|
||||
>排行配置暂未就绪,请稍后重试。</text
|
||||
>
|
||||
<view
|
||||
class="form-field form-field--picker"
|
||||
role="button"
|
||||
aria-label="选择出生日期"
|
||||
@click="openDatePicker('birthDate', '选择出生日期')"
|
||||
>
|
||||
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
|
||||
</view>
|
||||
<picker
|
||||
:range="lunarOptions.map((item) => item.label)"
|
||||
:value="optionIndex(lunarOptions, addForm.birthLunar)"
|
||||
@@ -260,17 +276,15 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<picker
|
||||
<view
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="addForm.deathDate"
|
||||
@change="selectDeathDate"
|
||||
class="form-field form-field--picker"
|
||||
role="button"
|
||||
aria-label="选择逝世日期"
|
||||
@click="openDatePicker('deathDate', '选择逝世日期')"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世日期</text
|
||||
><text>{{ addForm.deathDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
|
||||
</view>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
:range="lunarOptions.map((item) => item.label)"
|
||||
@@ -338,17 +352,15 @@
|
||||
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker
|
||||
<view
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="addForm.burialDate"
|
||||
@change="selectBurialDate"
|
||||
class="form-field form-field--picker"
|
||||
role="button"
|
||||
aria-label="选择安葬日期"
|
||||
@click="openDatePicker('burialDate', '选择安葬日期')"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>安葬日期</text
|
||||
><text>{{ addForm.burialDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text>安葬日期</text><text>{{ addForm.burialDate || "请选择" }}</text>
|
||||
</view>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input
|
||||
@@ -373,15 +385,6 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>排序值</text>
|
||||
<input
|
||||
v-model="addForm.sortOrder"
|
||||
type="number"
|
||||
placeholder="可选整数,值越小越靠前"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
<textarea
|
||||
@@ -427,6 +430,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<MemberDatePickerSheet
|
||||
:visible="datePickerVisible"
|
||||
:title="datePickerTitle"
|
||||
:value="datePickerValue"
|
||||
@cancel="closeDatePicker"
|
||||
@confirm="confirmDatePicker"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="discardDialogVisible"
|
||||
eyebrow="尚未保存"
|
||||
@@ -447,6 +458,7 @@ import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
@@ -486,10 +498,14 @@ const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const datePickerVisible = ref(false);
|
||||
const datePickerField = ref("");
|
||||
const datePickerTitle = ref("选择日期");
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const memberDetailRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const rankOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberCreationRequestController = createRequestController();
|
||||
const sensitiveProfileRequestController = createRequestController();
|
||||
@@ -503,6 +519,8 @@ const dictionaryRequestControllers = {
|
||||
const memberCreationGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
let rankLoadSequence = 0;
|
||||
let rankOptionsContext = "";
|
||||
|
||||
const relationIntents = Object.freeze(
|
||||
Object.fromEntries(
|
||||
@@ -526,6 +544,7 @@ const addForm = reactive({
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
rankId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
@@ -548,7 +567,6 @@ const addForm = reactive({
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({
|
||||
name: "",
|
||||
@@ -560,6 +578,8 @@ const fieldErrors = reactive({
|
||||
});
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
const rankOptionsState = ref("idle");
|
||||
const rankOptions = ref([]);
|
||||
const zodiacOptions = ref([]);
|
||||
const educationOptions = ref([]);
|
||||
const deathExpressionOptions = ref([]);
|
||||
@@ -568,6 +588,7 @@ const spouseRelationVariantOptions = ref([]);
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const isDeceased = computed(() => addForm.personStatus === "1");
|
||||
const datePickerValue = computed(() => addForm[datePickerField.value] || "");
|
||||
const activeRelationIntent = computed(
|
||||
() => relationIntents[relationType.value] || null,
|
||||
);
|
||||
@@ -617,6 +638,23 @@ const formCopy = computed(() =>
|
||||
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
|
||||
);
|
||||
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
|
||||
const rankPickerLabels = computed(() => [
|
||||
"请选择排行称谓",
|
||||
...rankOptions.value.map((item) => item.rankName),
|
||||
]);
|
||||
const rankOptionIndex = computed(() => {
|
||||
const index = rankOptions.value.findIndex((item) => item.rankId === addForm.rankId);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const selectedRankName = computed(
|
||||
() => rankOptions.value.find((item) => item.rankId === addForm.rankId)?.rankName || "",
|
||||
);
|
||||
const rankOptionStateLabel = computed(() => {
|
||||
if (rankOptionsState.value === "loading") return "正在读取";
|
||||
if (rankOptionsState.value === "error") return "暂时无法读取";
|
||||
if (rankOptionsState.value === "idle") return "请先选择性别和世代";
|
||||
return "当前条件暂无可选排行";
|
||||
});
|
||||
const resultCopy = computed(() => {
|
||||
if (memberCreationOutcomeUnknown.value) {
|
||||
return {
|
||||
@@ -675,6 +713,7 @@ const loadCurrentMember = async () => {
|
||||
});
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
currentMember.value = member;
|
||||
applyRelationDefaults();
|
||||
addState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -753,6 +792,7 @@ onLoad((query) => {
|
||||
void loadMemberOptions();
|
||||
void loadBusinessOptions();
|
||||
if (isFirstMember.value) {
|
||||
applyRelationDefaults();
|
||||
addState.value = "form";
|
||||
return;
|
||||
}
|
||||
@@ -763,6 +803,7 @@ onUnload(() => {
|
||||
loadSequence += 1;
|
||||
memberDetailRequestController.abort();
|
||||
memberOptionsRequestController.abort();
|
||||
rankOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberCreationRequestController.abort();
|
||||
sensitiveProfileRequestController.abort();
|
||||
@@ -772,10 +813,13 @@ onUnload(() => {
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
transientOpen: datePickerVisible.value || discardDialogVisible.value,
|
||||
dirty: addState.value === "form" && hasDraft.value,
|
||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"close-transient": () => {
|
||||
if (datePickerVisible.value) return closeDatePicker();
|
||||
return cancelDiscard();
|
||||
},
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
@@ -788,6 +832,7 @@ const clearError = (field) => {
|
||||
const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
addForm.relationVariantCode = "";
|
||||
applyRelationDefaults();
|
||||
clearError("relation");
|
||||
};
|
||||
const optionIndex = findMemberOptionIndex;
|
||||
@@ -795,6 +840,64 @@ const optionLabel = findMemberOptionLabel;
|
||||
const selectOption = (field, options, event) => {
|
||||
updateMemberFormOption(addForm, field, options, event);
|
||||
};
|
||||
const relationGeneration = () => {
|
||||
const currentGeneration = Number(currentMember.value?.generation);
|
||||
if (!Number.isSafeInteger(currentGeneration) || currentGeneration < 1) return "";
|
||||
if ([memberRelationTypes.FATHER, memberRelationTypes.MOTHER].includes(selectedRelationType.value)) {
|
||||
return String(Math.max(1, currentGeneration - 1));
|
||||
}
|
||||
if ([memberRelationTypes.SON, memberRelationTypes.DAUGHTER].includes(selectedRelationType.value)) {
|
||||
return String(currentGeneration + 1);
|
||||
}
|
||||
return String(currentGeneration);
|
||||
};
|
||||
const applyRelationDefaults = () => {
|
||||
addForm.generation = isFirstMember.value ? "1" : relationGeneration();
|
||||
if ([memberRelationTypes.FATHER, memberRelationTypes.SON].includes(selectedRelationType.value)) {
|
||||
addForm.sex = "0";
|
||||
} else if ([memberRelationTypes.MOTHER, memberRelationTypes.DAUGHTER].includes(selectedRelationType.value)) {
|
||||
addForm.sex = "1";
|
||||
}
|
||||
rankOptionsContext = "";
|
||||
addForm.rankId = "";
|
||||
void loadRankOptions();
|
||||
};
|
||||
const loadRankOptions = async () => {
|
||||
const generation = Number(isFirstMember.value ? 1 : addForm.generation);
|
||||
const sex = String(addForm.sex || "");
|
||||
const context = `${generation}:${sex}`;
|
||||
const activeLoad = ++rankLoadSequence;
|
||||
if (!Number.isSafeInteger(generation) || generation < 1 || !["0", "1", "2"].includes(sex)) {
|
||||
rankOptionsContext = "";
|
||||
rankOptions.value = [];
|
||||
rankOptionsState.value = "idle";
|
||||
addForm.rankId = "";
|
||||
return;
|
||||
}
|
||||
if (context !== rankOptionsContext) addForm.rankId = "";
|
||||
rankOptionsContext = context;
|
||||
rankOptionsState.value = "loading";
|
||||
try {
|
||||
const options = await lineageApi.getRankOptions(genealogyId.value, generation, sex, {
|
||||
requestController: rankOptionsRequestController,
|
||||
});
|
||||
if (!pageActive || activeLoad !== rankLoadSequence) return;
|
||||
rankOptions.value = options;
|
||||
rankOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== rankLoadSequence || isRequestCancelled(error)) return;
|
||||
rankOptions.value = [];
|
||||
rankOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const selectSex = (event) => {
|
||||
selectOption("sex", sexOptions, event);
|
||||
void loadRankOptions();
|
||||
};
|
||||
const selectRank = (event) => {
|
||||
const selectedIndex = Number(event.detail.value) - 1;
|
||||
addForm.rankId = rankOptions.value[selectedIndex]?.rankId || "";
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
updateMemberBindingMode(addForm, event);
|
||||
fieldErrors.bindingMode = "";
|
||||
@@ -835,14 +938,26 @@ const uploadAvatar = async () => {
|
||||
if (pageActive) isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectBirthDate = (event) => {
|
||||
addForm.birthDate = event.detail.value || "";
|
||||
const openDatePicker = (field, title) => {
|
||||
datePickerField.value = field;
|
||||
datePickerTitle.value = title;
|
||||
datePickerVisible.value = true;
|
||||
};
|
||||
const selectDeathDate = (event) => {
|
||||
addForm.deathDate = event.detail.value || "";
|
||||
const closeDatePicker = () => {
|
||||
datePickerVisible.value = false;
|
||||
};
|
||||
const selectBurialDate = (event) => {
|
||||
addForm.burialDate = event.detail.value || "";
|
||||
const confirmDatePicker = (value) => {
|
||||
if (!datePickerField.value) return closeDatePicker();
|
||||
addForm[datePickerField.value] = value;
|
||||
fieldErrors.dates = "";
|
||||
closeDatePicker();
|
||||
};
|
||||
const localToday = () => {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const validateAddForm = () => {
|
||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||
@@ -858,8 +973,15 @@ const validateAddForm = () => {
|
||||
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
|
||||
? "请填写有效的电子邮箱"
|
||||
: "";
|
||||
const today = localToday();
|
||||
fieldErrors.dates =
|
||||
addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
|
||||
addForm.birthDate && addForm.birthDate > today
|
||||
? "出生日期不能晚于今天"
|
||||
: addForm.deathDate && addForm.deathDate > today
|
||||
? "逝世日期不能晚于今天"
|
||||
: addForm.burialDate && addForm.burialDate > today
|
||||
? "安葬日期不能晚于今天"
|
||||
: addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
|
||||
? "逝世日期不能早于出生日期"
|
||||
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
|
||||
? "安葬日期不能早于逝世日期"
|
||||
@@ -884,6 +1006,7 @@ const submitAdd = async () => {
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
...(addForm.rankId ? { rankId: addForm.rankId } : {}),
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
@@ -908,7 +1031,6 @@ const submitAdd = async () => {
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1 }
|
||||
: {
|
||||
|
||||
+142
-42
@@ -49,7 +49,7 @@
|
||||
<picker
|
||||
:range="sexOptions.map((item) => item.label)"
|
||||
:value="optionIndex(sexOptions, editForm.sex)"
|
||||
@change="selectOption('sex', sexOptions, $event)"
|
||||
@change="selectSex"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>性别</text
|
||||
@@ -148,8 +148,28 @@
|
||||
type="number"
|
||||
placeholder="正整数"
|
||||
placeholder-class="form-placeholder"
|
||||
@blur="loadRankOptions"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
v-if="rankOptions.length"
|
||||
:range="rankPickerLabels"
|
||||
:value="rankOptionIndex"
|
||||
:disabled="rankOptionsState !== 'ready'"
|
||||
@change="selectRank"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text>排行称谓</text>
|
||||
<text>{{ selectedRankName || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-else class="form-field form-field--disabled">
|
||||
<text>排行称谓</text>
|
||||
<text>{{ rankOptionStateLabel }}</text>
|
||||
</view>
|
||||
<text v-if="rankOptionsState === 'error'" class="field-error"
|
||||
>排行配置暂未就绪,请稍后重试。</text
|
||||
>
|
||||
<picker
|
||||
:range="personOptionLabels"
|
||||
:value="personOptionIndex(editForm.fatherId)"
|
||||
@@ -170,16 +190,15 @@
|
||||
><text>{{ personOptionLabel(editForm.motherId) }}</text></view
|
||||
>
|
||||
</picker>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="editForm.birthDate"
|
||||
@change="selectDate('birthDate', $event)"
|
||||
<view
|
||||
class="form-field form-field--picker"
|
||||
role="button"
|
||||
aria-label="选择出生日期"
|
||||
@click="openDatePicker('birthDate', '选择出生日期')"
|
||||
>
|
||||
<view class="form-field form-field--picker"
|
||||
><text>出生日期</text
|
||||
><text>{{ editForm.birthDate || "未填写" }}</text></view
|
||||
>
|
||||
</picker>
|
||||
<text>出生日期</text>
|
||||
<text>{{ editForm.birthDate || "未填写" }}</text>
|
||||
</view>
|
||||
<picker
|
||||
:range="lunarOptions.map((item) => item.label)"
|
||||
:value="optionIndex(lunarOptions, editForm.birthLunar)"
|
||||
@@ -257,17 +276,16 @@
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<picker
|
||||
<view
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="editForm.deathDate"
|
||||
@change="selectDate('deathDate', $event)"
|
||||
class="form-field form-field--picker"
|
||||
role="button"
|
||||
aria-label="选择离世日期"
|
||||
@click="openDatePicker('deathDate', '选择离世日期')"
|
||||
>
|
||||
<view class="form-field form-field--picker"
|
||||
><text>离世日期</text
|
||||
><text>{{ editForm.deathDate || "在世或未填写" }}</text></view
|
||||
>
|
||||
</picker>
|
||||
<text>离世日期</text>
|
||||
<text>{{ editForm.deathDate || "在世或未填写" }}</text>
|
||||
</view>
|
||||
<picker
|
||||
v-if="isDeceased"
|
||||
:range="lunarOptions.map((item) => item.label)"
|
||||
@@ -329,17 +347,16 @@
|
||||
class="form-note"
|
||||
>遗传病史属于敏感健康信息,仅在已授权时读取和保存。</text
|
||||
>
|
||||
<picker
|
||||
<view
|
||||
v-if="isDeceased"
|
||||
mode="date"
|
||||
:value="editForm.burialDate"
|
||||
@change="selectDate('burialDate', $event)"
|
||||
class="form-field form-field--picker"
|
||||
role="button"
|
||||
aria-label="选择安葬日期"
|
||||
@click="openDatePicker('burialDate', '选择安葬日期')"
|
||||
>
|
||||
<view class="form-field form-field--picker"
|
||||
><text>安葬日期</text
|
||||
><text>{{ editForm.burialDate || "未填写" }}</text></view
|
||||
>
|
||||
</picker>
|
||||
<text>安葬日期</text>
|
||||
<text>{{ editForm.burialDate || "未填写" }}</text>
|
||||
</view>
|
||||
<view v-if="isDeceased" class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input
|
||||
@@ -361,15 +378,6 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>排序值</text>
|
||||
<input
|
||||
v-model="editForm.sortOrder"
|
||||
type="number"
|
||||
placeholder="可选整数,值越小越靠前"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
<textarea
|
||||
@@ -419,6 +427,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<MemberDatePickerSheet
|
||||
:visible="datePickerVisible"
|
||||
:title="datePickerTitle"
|
||||
:value="datePickerValue"
|
||||
@cancel="closeDatePicker"
|
||||
@confirm="confirmDatePicker"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="discardDialogVisible"
|
||||
eyebrow="资料尚未保存"
|
||||
@@ -439,6 +455,7 @@ import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
@@ -476,11 +493,15 @@ const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const datePickerVisible = ref(false);
|
||||
const datePickerField = ref("");
|
||||
const datePickerTitle = ref("选择日期");
|
||||
const errorMessage = ref("");
|
||||
const failedAction = ref("load");
|
||||
const memberDetailRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const rankOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberUpdateRequestController = createRequestController();
|
||||
const sensitiveProfileRequestController = createRequestController();
|
||||
@@ -491,6 +512,8 @@ const dictionaryRequestControllers = {
|
||||
};
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
let rankLoadSequence = 0;
|
||||
let rankOptionsContext = "";
|
||||
|
||||
const originalMember = ref(null);
|
||||
const baseline = ref("");
|
||||
@@ -504,6 +527,7 @@ const editForm = reactive({
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
rankId: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
@@ -541,6 +565,8 @@ const lunarOptions = memberFormOptions.lunar;
|
||||
const personStatusOptions = memberFormOptions.personStatus;
|
||||
const bindingModeOptions = memberFormOptions.bindingMode;
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const rankOptionsState = ref("idle");
|
||||
const rankOptions = ref([]);
|
||||
const personOptionLabels = computed(() =>
|
||||
personOptions.value.map((item) => item.label),
|
||||
);
|
||||
@@ -565,6 +591,26 @@ const memberOptionLabels = computed(() =>
|
||||
memberBindingOptions.value.map((item) => item.label),
|
||||
);
|
||||
const isDeceased = computed(() => editForm.personStatus === "1");
|
||||
const datePickerValue = computed(() => editForm[datePickerField.value] || "");
|
||||
const rankOptionIndex = computed(() => {
|
||||
const index = rankOptions.value.findIndex((item) => item.rankId === editForm.rankId);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const rankPickerLabels = computed(() => [
|
||||
"请选择排行称谓",
|
||||
...rankOptions.value.map((item) => item.rankName),
|
||||
]);
|
||||
const selectedRankName = computed(
|
||||
() =>
|
||||
rankOptions.value.find((item) => item.rankId === editForm.rankId)?.rankName ||
|
||||
(editForm.rankId === originalMember.value?.rankId ? originalMember.value?.rankName : "") ||
|
||||
"",
|
||||
);
|
||||
const rankOptionStateLabel = computed(() => {
|
||||
if (rankOptionsState.value === "loading") return "正在读取";
|
||||
if (rankOptionsState.value === "error") return "暂时无法读取";
|
||||
return selectedRankName.value || "暂无可选排行";
|
||||
});
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
const hasValidContext = computed(() =>
|
||||
@@ -654,6 +700,7 @@ const loadMember = async () => {
|
||||
sex: member.sex || "",
|
||||
generation: member.generation || "",
|
||||
generationName: member.generationName || "",
|
||||
rankId: member.rankId || "",
|
||||
fatherId:
|
||||
member.relatives.find((item) => item.relation === "父亲")?.id || "",
|
||||
motherId:
|
||||
@@ -696,6 +743,8 @@ const loadMember = async () => {
|
||||
errorMessage.value = "";
|
||||
failedAction.value = "load";
|
||||
editState.value = "form";
|
||||
rankOptionsContext = `${Number(editForm.generation)}:${String(editForm.sex || "")}`;
|
||||
void loadRankOptions();
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
originalMember.value = null;
|
||||
@@ -720,7 +769,7 @@ const loadPersonOptions = async () => {
|
||||
return null;
|
||||
}
|
||||
const name = item.name;
|
||||
return { value: personOptionId, label: `${name}(${personOptionId})` };
|
||||
return { value: personOptionId, label: name };
|
||||
})
|
||||
.filter(Boolean);
|
||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||
@@ -779,6 +828,32 @@ const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadRankOptions = async () => {
|
||||
const generation = Number(editForm.generation);
|
||||
const sex = String(editForm.sex || "");
|
||||
const context = `${generation}:${sex}`;
|
||||
const activeLoad = ++rankLoadSequence;
|
||||
if (!Number.isSafeInteger(generation) || generation < 1 || !["0", "1", "2"].includes(sex)) {
|
||||
rankOptions.value = [];
|
||||
rankOptionsState.value = "idle";
|
||||
return;
|
||||
}
|
||||
if (context !== rankOptionsContext) editForm.rankId = "";
|
||||
rankOptionsContext = context;
|
||||
rankOptionsState.value = "loading";
|
||||
try {
|
||||
const options = await lineageApi.getRankOptions(genealogyId.value, generation, sex, {
|
||||
requestController: rankOptionsRequestController,
|
||||
});
|
||||
if (!pageActive || activeLoad !== rankLoadSequence) return;
|
||||
rankOptions.value = options;
|
||||
rankOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== rankLoadSequence || isRequestCancelled(error)) return;
|
||||
rankOptions.value = [];
|
||||
rankOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
@@ -802,6 +877,7 @@ onUnload(() => {
|
||||
memberDetailRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
memberOptionsRequestController.abort();
|
||||
rankOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberUpdateRequestController.abort();
|
||||
sensitiveProfileRequestController.abort();
|
||||
@@ -811,10 +887,13 @@ onUnload(() => {
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
transientOpen: datePickerVisible.value || discardDialogVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"close-transient": () => {
|
||||
if (datePickerVisible.value) return closeDatePicker();
|
||||
return cancelDiscard();
|
||||
},
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
@@ -829,6 +908,16 @@ const optionLabel = findMemberOptionLabel;
|
||||
const selectOption = (field, options, event) => {
|
||||
updateMemberFormOption(editForm, field, options, event);
|
||||
};
|
||||
const selectSex = (event) => {
|
||||
const previousSex = editForm.sex;
|
||||
selectOption("sex", sexOptions, event);
|
||||
if (editForm.sex !== previousSex) editForm.rankId = "";
|
||||
void loadRankOptions();
|
||||
};
|
||||
const selectRank = (event) => {
|
||||
const selectedIndex = Number(event.detail.value) - 1;
|
||||
editForm.rankId = rankOptions.value[selectedIndex]?.rankId || "";
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
updateMemberBindingMode(editForm, event);
|
||||
fieldErrors.bindingMode = "";
|
||||
@@ -880,9 +969,19 @@ const uploadAvatar = async () => {
|
||||
if (pageActive) isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectDate = (field, event) => {
|
||||
editForm[field] = event.detail.value || "";
|
||||
const openDatePicker = (field, title) => {
|
||||
datePickerField.value = field;
|
||||
datePickerTitle.value = title;
|
||||
datePickerVisible.value = true;
|
||||
};
|
||||
const closeDatePicker = () => {
|
||||
datePickerVisible.value = false;
|
||||
};
|
||||
const confirmDatePicker = (value) => {
|
||||
if (!datePickerField.value) return closeDatePicker();
|
||||
editForm[datePickerField.value] = value;
|
||||
fieldErrors.dates = "";
|
||||
closeDatePicker();
|
||||
};
|
||||
const validateEditForm = () => {
|
||||
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
|
||||
@@ -936,6 +1035,7 @@ const saveMember = async () => {
|
||||
? Number(editForm.generation)
|
||||
: undefined,
|
||||
generationName: editForm.generationName,
|
||||
rankId: editForm.rankId,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
|
||||
+55
-309
@@ -1,346 +1,92 @@
|
||||
<template>
|
||||
<view
|
||||
class="rank-page"
|
||||
:class="{
|
||||
'rank-state--loading': rankState === 'loading',
|
||||
'rank-state--form': rankState === 'form',
|
||||
'rank-state--error': rankState === 'error',
|
||||
}"
|
||||
>
|
||||
<view class="rank-redirect-page">
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="rank-page__header"
|
||||
><PageHeader title="调整排行" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="rank-panel">
|
||||
<PageHeader title="编辑成员" />
|
||||
<view class="rank-redirect-panel">
|
||||
<AppLoading
|
||||
v-if="rankState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认待调整人物。"
|
||||
v-if="redirectState === 'loading'"
|
||||
text="正在打开成员资料"
|
||||
description="排行称谓已合并到成员编辑页面。"
|
||||
/>
|
||||
|
||||
<view v-else-if="rankState === 'form' && member" class="rank-form">
|
||||
<text class="form-eyebrow">同辈排行</text>
|
||||
<text class="form-title">调整{{ member.name }}的排行</text>
|
||||
<text class="form-copy"
|
||||
>这里只调整这位成员的排行;同辈成员的整体排序暂不能在这里修改。</text
|
||||
>
|
||||
|
||||
<view class="member-context">
|
||||
<text>当前成员</text
|
||||
><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
||||
</view>
|
||||
<view class="rank-field">
|
||||
<text>排序值</text>
|
||||
<input
|
||||
v-model="sortOrder"
|
||||
type="number"
|
||||
:disabled="savingRank"
|
||||
placeholder="请输入整数,值越小越靠前"
|
||||
placeholder-class="rank-placeholder"
|
||||
@input="rankError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="rankError" class="rank-error">{{ rankError }}</text>
|
||||
<view
|
||||
class="form-action"
|
||||
:class="{ 'form-action--disabled': savingRank }"
|
||||
@click="saveRank"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ savingRank ? "正在保存…" : "保存排行" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="rank-result">
|
||||
<text class="form-eyebrow">暂时无法打开成员页面</text>
|
||||
<text class="form-title">暂时无法读取成员资料</text>
|
||||
<text class="form-copy">{{ errorMessage }}</text>
|
||||
<view class="form-action" @click="goBack">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>返回世系树</text>
|
||||
<view v-else class="rank-redirect-error">
|
||||
<text class="rank-redirect-error__title">暂时无法打开成员资料</text>
|
||||
<text class="rank-redirect-error__copy">{{ errorMessage }}</text>
|
||||
<view class="rank-redirect-error__action" @click="goBack">
|
||||
<text>返回上一页</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
eyebrow="未保存修改"
|
||||
title="放弃排行修改?"
|
||||
message="当前排序值还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const member = ref(null);
|
||||
const redirectState = ref("loading");
|
||||
const errorMessage = ref("");
|
||||
const sortOrder = ref("");
|
||||
const savingRank = ref(false);
|
||||
const rankError = ref("");
|
||||
const committedSortOrder = ref("");
|
||||
const sortOrderBaseline = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const memberRankReadRequestController = createRequestController();
|
||||
const memberRankSaveRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const isDirty = computed(() =>
|
||||
rankState.value === "form" && sortOrder.value !== sortOrderBaseline.value,
|
||||
);
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
rankState.value = "loading";
|
||||
onLoad(async (query) => {
|
||||
const genealogyId = String(query?.genealogyId || "");
|
||||
const personId = String(query?.personId || "");
|
||||
if (!/^[1-9]\d*$/.test(genealogyId) || !/^[1-9]\d*$/.test(personId)) {
|
||||
redirectState.value = "error";
|
||||
errorMessage.value = "页面参数已经失效,请从成员资料重新进入。";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberRankReadRequestController,
|
||||
});
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
member.value = personDetail;
|
||||
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
|
||||
? ""
|
||||
: String(personDetail.sortOrder);
|
||||
sortOrderBaseline.value = sortOrder.value;
|
||||
committedSortOrder.value = "";
|
||||
errorMessage.value = "";
|
||||
rankState.value = "form";
|
||||
await returnTo("T05", { genealogyId, personId });
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
member.value = null;
|
||||
errorMessage.value =
|
||||
getRequestErrorMessage(error, "当前成员资料暂不可用,请返回世系树后重试。");
|
||||
rankState.value = "error";
|
||||
redirectState.value = "error";
|
||||
errorMessage.value = error?.message || "请返回后重新打开成员资料。";
|
||||
}
|
||||
};
|
||||
|
||||
const saveRank = async () => {
|
||||
if (savingRank.value || !member.value) return;
|
||||
const normalizedSortOrder = String(sortOrder.value).trim();
|
||||
if (!/^-?\d+$/.test(normalizedSortOrder)) {
|
||||
rankError.value = "请填写整数,例如 1、2、3。";
|
||||
return;
|
||||
}
|
||||
const numericSortOrder = Number(normalizedSortOrder);
|
||||
if (!Number.isSafeInteger(numericSortOrder)) {
|
||||
rankError.value = "排序值超出可保存范围";
|
||||
return;
|
||||
}
|
||||
savingRank.value = true;
|
||||
rankError.value = "";
|
||||
try {
|
||||
if (committedSortOrder.value !== normalizedSortOrder) {
|
||||
await lineageApi.updatePersonSortOrder(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
numericSortOrder,
|
||||
{ requestController: memberRankSaveRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
committedSortOrder.value = normalizedSortOrder;
|
||||
sortOrderBaseline.value = normalizedSortOrder;
|
||||
}
|
||||
await returnTo("T01", {
|
||||
genealogyId: genealogyId.value,
|
||||
selectedId: personId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
rankError.value = committedSortOrder.value === normalizedSortOrder
|
||||
? "排行已经保存,但页面返回失败。请再次点击保存重试返回。"
|
||||
: getRequestErrorMessage(error, "排行尚未保存,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) savingRank.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: savingRank.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(genealogyId.value) ||
|
||||
!/^[1-9]\d*$/.test(personId.value) ||
|
||||
query.mode !== "rank"
|
||||
) {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "请从成员资料页重新进入排行调整。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
memberRankReadRequestController.abort();
|
||||
memberRankSaveRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.rank-page {
|
||||
display: flex;
|
||||
.rank-redirect-page {
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.rank-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.rank-panel {
|
||||
@include adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
|
||||
padding: 7.5% 8%;
|
||||
}
|
||||
.form-eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-context,
|
||||
.rank-field {
|
||||
@include adaptive-tree-field;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 12rpx 22rpx;
|
||||
}
|
||||
.member-context text:first-child,
|
||||
.rank-field text {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-context text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
.rank-field input {
|
||||
min-width: 0;
|
||||
min-height: var(--app-touch-min);
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.rank-placeholder { color: $ink-muted; }
|
||||
.rank-error { display: block; margin-top: 12rpx; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: var(--app-touch-min);
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.form-action--disabled { opacity: 0.58; pointer-events: none; }
|
||||
.form-action image,
|
||||
.form-action text {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.form-action text {
|
||||
|
||||
.rank-redirect-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 48rpx 32rpx;
|
||||
}
|
||||
|
||||
.rank-redirect-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 64rpx 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rank-redirect-error__title {
|
||||
color: $ink;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.rank-result {
|
||||
margin-top: 30%;
|
||||
text-align: center;
|
||||
|
||||
.rank-redirect-error__copy {
|
||||
color: $ink-muted;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.rank-result .form-eyebrow,
|
||||
.rank-result .form-copy {
|
||||
text-align: center;
|
||||
}
|
||||
.rank-result .form-action {
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.rank-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
|
||||
.rank-redirect-error__action {
|
||||
min-width: 240rpx;
|
||||
padding: 22rpx 36rpx;
|
||||
border: 1rpx solid rgba($brand-red, 0.4);
|
||||
color: $brand-red;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
+53
-88
@@ -26,10 +26,9 @@
|
||||
aria-label="回到当前成员"
|
||||
hover-class="tree-action--pressed"
|
||||
@click="recenterSelectedMember"
|
||||
>回到当前</text
|
||||
>回到当前</text
|
||||
>
|
||||
<text @click="treeState = 'landscape'">阅读提示</text>
|
||||
<text @click="toRank">调整排行</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -105,19 +104,14 @@
|
||||
>
|
||||
<view class="member-node__surface">
|
||||
<view class="member-node__portrait">
|
||||
<AppAvatar :sex="member.sex" />
|
||||
<AppAvatar
|
||||
:sex="member.sex"
|
||||
:src="member.avatarFile?.accessUrl || ''"
|
||||
/>
|
||||
<text class="node-relation">{{ member.relation }}</text>
|
||||
</view>
|
||||
<image
|
||||
class="member-node__divider"
|
||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation">{{
|
||||
nodeRelationText(member)
|
||||
}}</text>
|
||||
<text class="node-years">{{ member.treeYears || member.years }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -243,13 +237,6 @@ const memberActions = Object.freeze([
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SIBLING,
|
||||
},
|
||||
{
|
||||
key: "ADJUST_RANK",
|
||||
label: "调整排行",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T06",
|
||||
mode: "rank",
|
||||
},
|
||||
{
|
||||
key: "ADD_SON",
|
||||
label: "添加儿子",
|
||||
@@ -282,10 +269,6 @@ const managementActions = computed(() =>
|
||||
memberActions.filter((action) => action.group === "MANAGEMENT"),
|
||||
);
|
||||
|
||||
const nodeRelationText = (member) => {
|
||||
const branch = String(member.branch || "").replace(/字辈$/, "");
|
||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
||||
};
|
||||
const treeLayout = computed(() => createTreeLayout(members.value));
|
||||
const layoutMembers = computed(() => treeLayout.value.members);
|
||||
const treeMetrics = computed(() => treeLayout.value.metrics);
|
||||
@@ -493,18 +476,6 @@ const toDirectory = () =>
|
||||
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
||||
const toPedigree = () =>
|
||||
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
||||
const toRank = () =>
|
||||
selectedMember.value
|
||||
? openPage(
|
||||
"T06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
personId: String(selectedMember.value.id),
|
||||
mode: "rank",
|
||||
},
|
||||
"T01",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const openMemberPanel = (member) => {
|
||||
selectedMember.value = member;
|
||||
memberActionPanelVisible.value = true;
|
||||
@@ -521,8 +492,6 @@ const openMemberAction = (action) => {
|
||||
};
|
||||
if (action.routeKey === "T04") {
|
||||
params.relationType = action.relationType;
|
||||
} else if (action.routeKey === "T06") {
|
||||
params.mode = action.mode;
|
||||
}
|
||||
return openPage(action.routeKey, params, "T01");
|
||||
};
|
||||
@@ -584,7 +553,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.tree-stage--lineage {
|
||||
display: grid;
|
||||
grid-template-columns: 72rpx minmax(0, 1fr);
|
||||
grid-template-columns: 112rpx minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.tree-scroll {
|
||||
@@ -595,7 +564,7 @@ const openMemberAction = (action) => {
|
||||
.generation-rail {
|
||||
display: grid;
|
||||
z-index: 4;
|
||||
width: 72rpx;
|
||||
width: 112rpx;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
||||
@@ -627,7 +596,7 @@ const openMemberAction = (action) => {
|
||||
display: grid;
|
||||
justify-self: center;
|
||||
z-index: 3;
|
||||
width: 62rpx;
|
||||
width: 100rpx;
|
||||
height: 104rpx;
|
||||
color: #8f6c3f;
|
||||
font-size: clamp(12px, 18rpx, 14px);
|
||||
@@ -647,17 +616,17 @@ const openMemberAction = (action) => {
|
||||
.generation-band__copy {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
height: 100%;
|
||||
padding: 0 4rpx;
|
||||
padding: 8rpx 4rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 252, 244, 0.9);
|
||||
}
|
||||
.generation-band__copy text {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.generation-band__copy text:last-child {
|
||||
@@ -677,8 +646,8 @@ const openMemberAction = (action) => {
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
z-index: 2;
|
||||
width: 160rpx;
|
||||
height: 224rpx;
|
||||
width: 154rpx;
|
||||
height: 200rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -689,85 +658,81 @@ const openMemberAction = (action) => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 9rpx 8rpx 8rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 252, 244, 0.96);
|
||||
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
|
||||
padding: 10rpx;
|
||||
background: url("/static/assets/modules/tree/transparent/member-node-portrait.png") center / 100% 100% no-repeat;
|
||||
}
|
||||
.member-node__portrait {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
width: 134rpx;
|
||||
height: 126rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid #c49a57;
|
||||
border-radius: 50%;
|
||||
border-radius: 2rpx;
|
||||
background: #fffdf7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-node__divider {
|
||||
width: 56rpx;
|
||||
height: 14rpx;
|
||||
margin-top: 3rpx;
|
||||
}
|
||||
.member-node__copy {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 1rpx;
|
||||
min-height: 52rpx;
|
||||
margin-top: 4rpx;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
.node-years {
|
||||
.node-relation {
|
||||
display: block;
|
||||
}
|
||||
.node-name {
|
||||
display: -webkit-box;
|
||||
color: #6b2419;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
overflow: hidden;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: #d96529;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(14px, 24rpx, 18px);
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.node-relation {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: 5rpx;
|
||||
z-index: 1;
|
||||
min-width: 54rpx;
|
||||
max-width: 116rpx;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #644b2e;
|
||||
font-size: clamp(12px, 18rpx, 14px);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-years {
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #695b4c;
|
||||
font-size: clamp(12px, 17rpx, 14px);
|
||||
line-height: 1.2;
|
||||
padding: 1rpx 6rpx;
|
||||
transform: translateX(50%);
|
||||
border: 1rpx solid #e66d24;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #e66d24;
|
||||
font-size: clamp(12px, 22rpx, 15px);
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-node--spouse .member-node__surface {
|
||||
border-color: #c39a61;
|
||||
background: rgba(255, 250, 241, 0.98);
|
||||
background-image: url("/static/assets/modules/tree/transparent/member-node-portrait.png");
|
||||
}
|
||||
.member-node--selected {
|
||||
z-index: 3;
|
||||
}
|
||||
.member-node--selected .member-node__surface {
|
||||
border-color: #c55344;
|
||||
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
|
||||
filter: drop-shadow(0 4rpx 5rpx rgba(159, 23, 15, 0.2));
|
||||
}
|
||||
.member-node--selected .member-node__portrait {
|
||||
border-color: #c55344;
|
||||
}
|
||||
.member-node--selected .node-name {
|
||||
color: $brand-red;
|
||||
color: #d96529;
|
||||
}
|
||||
.tree-state-card {
|
||||
display: grid;
|
||||
|
||||
+18
-30
@@ -31,7 +31,7 @@
|
||||
:show-scrollbar="false"
|
||||
:style="pedigreeScrollStyle"
|
||||
>
|
||||
<view class="pedigree-sheet" :style="pedigreeSheetStyle">
|
||||
<view class="pedigree-sheet" :style="getPedigreeSheetStyle(page)">
|
||||
<view class="pedigree-column pedigree-column--legend">
|
||||
<view class="pedigree-column__relation"><text>身份</text></view>
|
||||
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
|
||||
@@ -40,15 +40,14 @@
|
||||
|
||||
<view
|
||||
v-for="(member, memberIndex) in page.members"
|
||||
:key="member?.id || `empty-${pageIndex}-${memberIndex}`"
|
||||
:key="member.id || `member-${pageIndex}-${memberIndex}`"
|
||||
class="member-node"
|
||||
:class="{
|
||||
'member-node--blank': !member,
|
||||
'member-node--selected': selectedId === String(member?.id || ''),
|
||||
'member-node--spouse': Boolean(member?.spouseOf),
|
||||
'member-node--selected': selectedId === String(member.id),
|
||||
'member-node--spouse': Boolean(member.spouseOf),
|
||||
}"
|
||||
>
|
||||
<view v-if="member" class="member-node__surface">
|
||||
<view class="member-node__surface">
|
||||
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
|
||||
<view
|
||||
class="member-node__name"
|
||||
@@ -65,22 +64,16 @@
|
||||
:aria-label="`查看${member.name}的生平详情`"
|
||||
@click.stop="openMemberDetail(member)"
|
||||
>
|
||||
<text class="node-relation">{{ member.branch }}</text>
|
||||
<text class="node-years">{{ memberDetail(member) }}</text>
|
||||
<text>{{ memberDetailText(member) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="member-node__surface">
|
||||
<view class="member-node__relation"></view>
|
||||
<view class="member-node__name"></view>
|
||||
<view class="member-node__copy"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<view class="generation-rail" :style="pedigreeSheetStyle">
|
||||
<view class="generation-rail" :style="pedigreeHeightStyle">
|
||||
<view class="generation-band">
|
||||
<image
|
||||
class="generation-band__arrow generation-band__arrow--previous"
|
||||
@@ -152,7 +145,7 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const PEDIGREE_PAGE_SIZE = 5;
|
||||
const PEDIGREE_PAGE_SIZE = 3;
|
||||
const genealogyId = ref("");
|
||||
const treeState = ref("loading");
|
||||
const treeLoadError = ref("");
|
||||
@@ -198,14 +191,13 @@ const pedigreePages = computed(() => {
|
||||
const branch = row.members.find((member) => member.branch)?.branch;
|
||||
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
|
||||
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
|
||||
while (pageMembers.length < PEDIGREE_PAGE_SIZE) pageMembers.push(null);
|
||||
pages.push({
|
||||
generation: row.generation,
|
||||
label: row.label,
|
||||
memberCount: row.members.length,
|
||||
summary: [branch, `${row.members.length} 位成员`]
|
||||
summary: [branch, `${row.members.length}位成员`]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
.join("·"),
|
||||
members: pageMembers,
|
||||
});
|
||||
}
|
||||
@@ -219,14 +211,18 @@ const detailLength = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
...members.value.map((member) =>
|
||||
Array.from(`${member.branch || ""}${memberDetail(member)}`).length,
|
||||
Array.from(memberDetailText(member)).length,
|
||||
),
|
||||
),
|
||||
);
|
||||
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
|
||||
const pedigreeSheetStyle = computed(() => ({
|
||||
const pedigreeHeightStyle = computed(() => ({
|
||||
height: `${sheetHeight.value}rpx`,
|
||||
}));
|
||||
const getPedigreeSheetStyle = (page) => ({
|
||||
...pedigreeHeightStyle.value,
|
||||
gridTemplateColumns: `104rpx repeat(${page.members.length}, minmax(0, 1fr))`,
|
||||
});
|
||||
const pedigreeScrollStyle = computed(() => ({
|
||||
height: `${sheetHeight.value}rpx`,
|
||||
}));
|
||||
@@ -254,6 +250,8 @@ const handlePedigreePageChange = (event) => {
|
||||
const memberRelation = (member) =>
|
||||
member.spouseOf ? "配偶" : member.relation || "家谱成员";
|
||||
const memberDetail = (member) => member.treeYears || member.years || "生卒待补";
|
||||
const memberDetailText = (member) =>
|
||||
[member.branch, memberDetail(member)].filter(Boolean).join("·");
|
||||
const openMemberProfile = (member) =>
|
||||
openPage(
|
||||
"T03",
|
||||
@@ -463,9 +461,6 @@ onUnload(() => {
|
||||
.member-node__surface {
|
||||
display: contents;
|
||||
}
|
||||
.member-node--blank {
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-node--selected .member-node__relation,
|
||||
.member-node--selected .member-node__name,
|
||||
.member-node--selected .member-node__copy {
|
||||
@@ -477,13 +472,6 @@ onUnload(() => {
|
||||
.member-node--spouse .member-node__relation {
|
||||
color: #7a4d43;
|
||||
}
|
||||
.node-relation {
|
||||
color: #675f57;
|
||||
}
|
||||
.node-years {
|
||||
color: #3f3934;
|
||||
text-combine-upright: all;
|
||||
}
|
||||
.generation-rail {
|
||||
width: 92rpx;
|
||||
border-left: 1rpx solid #e2e2e2;
|
||||
|
||||
Reference in New Issue
Block a user