修改完成待测试
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user