修改完成待测试

This commit is contained in:
2026-09-13 17:45:47 +08:00
parent 9ad572907b
commit bbc024f7f2
80 changed files with 3844 additions and 1672 deletions
+6
View File
@@ -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 {
+187 -8
View File
@@ -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;
}
+71 -1
View File
@@ -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;
+1 -1
View File
@@ -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 = () =>
+145 -8
View File
@@ -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
View File
@@ -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;
+25 -11
View File
@@ -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%;
+7
View File
@@ -0,0 +1,7 @@
<template>
<SiteHome subpage-mode />
</template>
<script setup>
import SiteHome from "@/pages/family/site-home.vue";
</script>
+354
View File
@@ -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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/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
View File
@@ -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;