527 lines
17 KiB
Vue
527 lines
17 KiB
Vue
<template>
|
||
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||
<ModulePageBackground module="family" />
|
||
<view class="article-detail-header"
|
||
><PageHeader title="谱文详情" custom-back @back="requestBack"
|
||
/></view>
|
||
|
||
<view class="article-detail-content">
|
||
<AppLoading
|
||
v-if="articleState === 'loading'"
|
||
text="正在读取谱文"
|
||
description="请稍候,正在同步谱文正文。"
|
||
/>
|
||
<view v-else-if="articleState === 'ready'" class="article-card">
|
||
<image
|
||
v-if="article.coverFile?.accessUrl"
|
||
class="article-card__cover"
|
||
:src="article.coverFile.accessUrl"
|
||
mode="aspectFill"
|
||
role="button"
|
||
aria-label="查看谱文封面"
|
||
@click="previewCover"
|
||
/>
|
||
<text v-if="article.category" class="article-card__category">{{
|
||
article.category
|
||
}}</text>
|
||
<text class="article-card__title">{{ article.title }}</text>
|
||
<text class="article-card__meta"
|
||
>{{ article.author }} · {{ article.time || "未标注时间" }}</text
|
||
>
|
||
<text v-if="article.summary" class="article-card__summary">{{
|
||
article.summary
|
||
}}</text>
|
||
<view class="article-card__divider" />
|
||
<view v-if="article.contentProtected && !article.contentUnlocked" class="article-lock-card">
|
||
<text>这篇谱文已设置内容密码</text>
|
||
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
|
||
<button class="article-lock-card__recovery" @click="passwordRecoveryVisible = true">忘记内容密码?</button>
|
||
<text v-if="protectionError">{{ protectionError }}</text>
|
||
</view>
|
||
<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"
|
||
class="article-card__actions"
|
||
>
|
||
<AppButton
|
||
v-if="article.canEdit && article.content"
|
||
compact
|
||
type="secondary"
|
||
label="编辑谱文"
|
||
@click="editArticle"
|
||
/>
|
||
<AppButton
|
||
v-if="article.canDelete"
|
||
compact
|
||
type="secondary"
|
||
label="删除谱文"
|
||
@click="requestDeleteArticle"
|
||
/>
|
||
<AppButton
|
||
v-if="article.canManageProtection"
|
||
compact
|
||
type="secondary"
|
||
:label="article.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||
@click="openProtectionDialog('set')"
|
||
/>
|
||
<AppButton
|
||
v-if="article.canManageProtection && article.contentProtected"
|
||
compact
|
||
type="secondary"
|
||
label="关闭内容密码"
|
||
@click="openProtectionDialog('disable')"
|
||
/>
|
||
</view>
|
||
<text v-if="deleteError" class="article-card__error">{{ deleteError }}</text>
|
||
</view>
|
||
<view v-else class="article-state-card">
|
||
<text>{{ stateCopy.title }}</text>
|
||
<text>{{ stateCopy.copy }}</text>
|
||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||
</view>
|
||
</view>
|
||
<AppDialog
|
||
:visible="deleteConfirmationVisible"
|
||
:close-on-mask="false"
|
||
eyebrow="删除确认"
|
||
title="将这篇谱文移至回收站?"
|
||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||
confirm-text="移至回收站"
|
||
cancel-text="保留谱文"
|
||
show-cancel
|
||
@confirm="deleteArticle"
|
||
@cancel="closeDeleteConfirmation"
|
||
/>
|
||
<AppDialog
|
||
:visible="protectionDialogVisible"
|
||
eyebrow="内容密码"
|
||
:title="protectionMode === 'disable' ? '关闭内容密码?' : article?.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||
:message="protectionMode === 'disable' ? '关闭后,有权查看谱文的成员无需密码即可阅读正文。' : '设置8至128位密码,之后阅读正文需要先验证。'"
|
||
:confirm-text="protectionSubmitting ? '正在保存' : protectionMode === 'disable' ? '确认关闭' : '确认保存'"
|
||
cancel-text="取消"
|
||
show-cancel
|
||
:close-on-mask="false"
|
||
@confirm="confirmProtectionChange"
|
||
@cancel="closeProtectionDialog"
|
||
>
|
||
<input v-if="protectionMode === 'set'" v-model="protectionPassword" class="protection-dialog-input" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||
<text v-if="protectionError" class="article-card__error">{{ protectionError }}</text>
|
||
</AppDialog>
|
||
<ContentPasswordRecoveryDialog
|
||
:visible="passwordRecoveryVisible"
|
||
:genealogy-id="genealogyId"
|
||
resource-type="ARTICLE"
|
||
:resource-id="articleId"
|
||
@close="passwordRecoveryVisible = false"
|
||
@complete="completePasswordRecovery"
|
||
@busy-change="passwordRecoveryBusy = $event"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from "vue";
|
||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||
import AppButton from "@/components/AppButton.vue";
|
||
import AppDialog from "@/components/AppDialog.vue";
|
||
import AppLoading from "@/components/AppLoading.vue";
|
||
import 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 {
|
||
createRequestController,
|
||
isRequestCancelled
|
||
} from "@/services/api/request-controller.js";
|
||
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||
|
||
const genealogyId = ref("");
|
||
const articleId = ref("");
|
||
const articleState = ref("loading");
|
||
const article = ref(null);
|
||
const deleteConfirmationVisible = ref(false);
|
||
const deleting = ref(false);
|
||
const deleteError = ref("");
|
||
const articleAccessToken = ref("");
|
||
const protectionPassword = ref("");
|
||
const protectionError = ref("");
|
||
const protectionDialogVisible = ref(false);
|
||
const protectionMode = ref("set");
|
||
const protectionSubmitting = ref(false);
|
||
const passwordRecoveryVisible = ref(false);
|
||
const passwordRecoveryBusy = ref(false);
|
||
const articleReadController = createRequestController();
|
||
const articleProtectionController = createRequestController();
|
||
const articleDeleteController = createRequestController();
|
||
let pageActive = true;
|
||
let skipInitialShowRefresh = true;
|
||
|
||
const hasValidContext = computed(
|
||
() =>
|
||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||
);
|
||
const previewCover = () => {
|
||
const url = article.value?.coverFile?.accessUrl;
|
||
if (!url || typeof uni?.previewImage !== "function") return;
|
||
uni.previewImage({ current: url, urls: [url] });
|
||
};
|
||
const stateCopy = computed(() => {
|
||
if (!hasValidContext.value) {
|
||
return {
|
||
title: "暂时无法打开谱文",
|
||
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||
action: "返回上一页",
|
||
};
|
||
}
|
||
if (articleState.value === "missing") {
|
||
return {
|
||
title: "该谱文已不存在",
|
||
copy: "它可能已被作者删除,或你暂时无法查看。",
|
||
action: "返回谱文列表",
|
||
};
|
||
}
|
||
return {
|
||
title: "谱文暂时无法读取",
|
||
copy: "请检查网络后重新读取。",
|
||
action: "重新读取",
|
||
};
|
||
});
|
||
|
||
const loadArticle = async (accessToken = articleAccessToken.value) => {
|
||
if (!hasValidContext.value) {
|
||
articleState.value = "invalid";
|
||
return;
|
||
}
|
||
articleState.value = "loading";
|
||
try {
|
||
const current = await familyArticleApi.getArticleDetail(
|
||
genealogyId.value,
|
||
articleId.value,
|
||
accessToken,
|
||
{ requestController: articleReadController },
|
||
);
|
||
if (!pageActive) return;
|
||
article.value = current;
|
||
articleState.value = "ready";
|
||
} catch (error) {
|
||
if (!pageActive || isRequestCancelled(error)) return;
|
||
articleState.value =
|
||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||
? "missing"
|
||
: "error";
|
||
}
|
||
};
|
||
|
||
onLoad((query) => {
|
||
genealogyId.value = String(query?.genealogyId || "");
|
||
articleId.value = String(query?.articleId || "");
|
||
void loadArticle();
|
||
});
|
||
onShow(() => {
|
||
if (skipInitialShowRefresh) {
|
||
skipInitialShowRefresh = false;
|
||
return;
|
||
}
|
||
if (hasValidContext.value) void loadArticle();
|
||
});
|
||
onUnload(() => {
|
||
pageActive = false;
|
||
articleReadController.abort();
|
||
articleProtectionController.abort();
|
||
articleDeleteController.abort();
|
||
});
|
||
|
||
const backToArticles = () =>
|
||
hasValidContext.value
|
||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||
: goBack();
|
||
const handleStateAction = () =>
|
||
articleState.value === "error" ? loadArticle() : backToArticles();
|
||
const requestDeleteArticle = () => {
|
||
if (!article.value?.canDelete || deleting.value) return;
|
||
deleteError.value = "";
|
||
deleteConfirmationVisible.value = true;
|
||
};
|
||
const unlockArticle = async () => {
|
||
if (protectionSubmitting.value) return;
|
||
if (protectionPassword.value.length < 8 || protectionPassword.value.length > 128) {
|
||
protectionError.value = "请输入8至128位内容密码。";
|
||
return;
|
||
}
|
||
protectionSubmitting.value = true;
|
||
protectionError.value = "";
|
||
try {
|
||
const grant = await familyArticleApi.unlockArticle(
|
||
genealogyId.value,
|
||
articleId.value,
|
||
protectionPassword.value,
|
||
{ requestController: articleProtectionController },
|
||
);
|
||
articleAccessToken.value = grant.accessToken;
|
||
protectionPassword.value = "";
|
||
await loadArticle(grant.accessToken);
|
||
} catch (error) {
|
||
if (!isRequestCancelled(error)) protectionError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
|
||
} finally {
|
||
protectionSubmitting.value = false;
|
||
}
|
||
};
|
||
const completePasswordRecovery = (newPassword) => {
|
||
protectionPassword.value = newPassword;
|
||
protectionError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||
};
|
||
const openProtectionDialog = (mode) => {
|
||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||
protectionMode.value = mode;
|
||
protectionPassword.value = "";
|
||
protectionError.value = "";
|
||
protectionDialogVisible.value = true;
|
||
};
|
||
const closeProtectionDialog = () => {
|
||
if (protectionSubmitting.value) return;
|
||
protectionDialogVisible.value = false;
|
||
protectionPassword.value = "";
|
||
protectionError.value = "";
|
||
};
|
||
const confirmProtectionChange = async () => {
|
||
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||
if (protectionMode.value === "set" && (protectionPassword.value.length < 8 || protectionPassword.value.length > 128)) {
|
||
protectionError.value = "请输入8至128位内容密码。";
|
||
return;
|
||
}
|
||
protectionSubmitting.value = true;
|
||
protectionError.value = "";
|
||
try {
|
||
if (protectionMode.value === "disable") {
|
||
await familyArticleApi.disableArticlePassword(genealogyId.value, articleId.value, {
|
||
requestController: articleProtectionController,
|
||
});
|
||
} else {
|
||
await familyArticleApi.setArticlePassword(
|
||
genealogyId.value,
|
||
articleId.value,
|
||
protectionPassword.value,
|
||
{ requestController: articleProtectionController },
|
||
);
|
||
}
|
||
if (!pageActive) return;
|
||
articleAccessToken.value = "";
|
||
protectionDialogVisible.value = false;
|
||
protectionPassword.value = "";
|
||
await loadArticle("");
|
||
} catch (error) {
|
||
if (pageActive && !isRequestCancelled(error))
|
||
protectionError.value = getRequestErrorMessage(
|
||
error,
|
||
"内容密码设置没有保存,请稍后重试。",
|
||
);
|
||
} finally {
|
||
if (pageActive) protectionSubmitting.value = false;
|
||
}
|
||
};
|
||
const editArticle = () =>
|
||
article.value?.canEdit && article.value.content && !deleting.value
|
||
? openPage(
|
||
"F06",
|
||
{
|
||
genealogyId: genealogyId.value,
|
||
articleId: articleId.value,
|
||
mode: "edit",
|
||
},
|
||
"F05",
|
||
)
|
||
: Promise.resolve(false);
|
||
const closeDeleteConfirmation = () => {
|
||
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||
};
|
||
const requestBack = () => {
|
||
if (deleting.value || protectionSubmitting.value || passwordRecoveryBusy.value) return true;
|
||
if (passwordRecoveryVisible.value) {
|
||
passwordRecoveryVisible.value = false;
|
||
return true;
|
||
}
|
||
if (protectionDialogVisible.value) {
|
||
closeProtectionDialog();
|
||
return true;
|
||
}
|
||
if (deleteConfirmationVisible.value) {
|
||
closeDeleteConfirmation();
|
||
return true;
|
||
}
|
||
return backToArticles();
|
||
};
|
||
onBackPress((event) => handleBackPress(event, requestBack));
|
||
const deleteArticle = async () => {
|
||
if (!article.value?.canDelete || deleting.value) return;
|
||
deleting.value = true;
|
||
deleteError.value = "";
|
||
let deletionCommitted = false;
|
||
try {
|
||
await familyArticleApi.deleteArticle(genealogyId.value, articleId.value, {
|
||
requestController: articleDeleteController,
|
||
});
|
||
deletionCommitted = true;
|
||
if (!pageActive) return;
|
||
deleteConfirmationVisible.value = false;
|
||
article.value = null;
|
||
articleState.value = "missing";
|
||
await backToArticles();
|
||
} catch (error) {
|
||
if (!pageActive) return;
|
||
if (deletionCommitted) {
|
||
deleteConfirmationVisible.value = false;
|
||
article.value = null;
|
||
articleState.value = "missing";
|
||
return;
|
||
}
|
||
if (isRequestCancelled(error)) return;
|
||
deleteError.value = getRequestErrorMessage(error, "谱文删除失败,请稍后重试。");
|
||
deleteConfirmationVisible.value = false;
|
||
} finally {
|
||
if (pageActive) deleting.value = false;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
@import "../../styles/adaptive-frame-profiles.scss";
|
||
|
||
.article-detail-page {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
flex-direction: column;
|
||
background: $paper;
|
||
}
|
||
.article-detail-header,
|
||
.article-detail-content {
|
||
z-index: 1;
|
||
}
|
||
.article-detail-content {
|
||
flex: 1;
|
||
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||
}
|
||
.article-card,
|
||
.article-state-card {
|
||
@include adaptive-family-content;
|
||
box-sizing: border-box;
|
||
background-color: rgba($paper, 0.82);
|
||
}
|
||
.article-card {
|
||
margin-top: 18rpx;
|
||
padding: 34rpx 30rpx;
|
||
}
|
||
.article-card__category,
|
||
.article-card__title,
|
||
.article-card__meta,
|
||
.article-card__summary,
|
||
.article-card__content,
|
||
.article-card__views {
|
||
display: block;
|
||
}
|
||
.article-card__category {
|
||
color: $brand-red;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
font-weight: 700;
|
||
}
|
||
.article-card__title {
|
||
margin-top: 14rpx;
|
||
color: $ink;
|
||
font-family: STKaiti, KaiTi, serif;
|
||
font-size: clamp(20px, 40rpx, 26px);
|
||
font-weight: 700;
|
||
line-height: 1.32;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.article-card__meta {
|
||
margin-top: 16rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(14px, 22rpx, 17px);
|
||
}
|
||
.article-card__summary {
|
||
margin-top: 22rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(15px, 25rpx, 18px);
|
||
line-height: 1.6;
|
||
}
|
||
.article-card__divider {
|
||
height: 1rpx;
|
||
margin: 26rpx 0;
|
||
background: rgba(128, 89, 49, 0.22);
|
||
}
|
||
.article-card__content {
|
||
color: $ink;
|
||
font-size: clamp(16px, 28rpx, 20px);
|
||
line-height: 1.85;
|
||
overflow-wrap: anywhere;
|
||
white-space: pre-wrap;
|
||
}
|
||
.article-card__views {
|
||
margin-top: 30rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
text-align: right;
|
||
}
|
||
.article-card__cover {
|
||
width: 100%;
|
||
height: 360rpx;
|
||
margin-bottom: 22rpx;
|
||
border-radius: 10rpx;
|
||
background: rgba(128, 89, 49, 0.12);
|
||
}
|
||
.article-card__actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10rpx;
|
||
justify-content: flex-end;
|
||
margin-top: 18rpx;
|
||
}
|
||
.article-lock-card { margin: 16rpx 0; padding: 24rpx; border: 1rpx solid rgba(159, 23, 15, 0.3); border-radius: 10rpx; background: rgba(159, 23, 15, 0.05); }
|
||
.article-lock-card > text { display: block; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||
.article-lock-card__recovery { min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: #9e251b; font-size: clamp(13px, 22rpx, 16px); }
|
||
.article-lock-card__recovery::after { border: 0; }
|
||
.article-lock-card input,
|
||
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 80rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||
.article-card__error {
|
||
margin-top: 12rpx;
|
||
color: $brand-red;
|
||
font-size: clamp(13px, 21rpx, 16px);
|
||
line-height: 1.5;
|
||
text-align: right;
|
||
}
|
||
.article-state-card {
|
||
min-height: 350rpx;
|
||
margin-top: 36rpx;
|
||
padding: 78rpx 52rpx 50rpx;
|
||
text-align: center;
|
||
}
|
||
.article-state-card > text {
|
||
display: block;
|
||
}
|
||
.article-state-card > text:first-child {
|
||
color: $ink;
|
||
font-family: STKaiti, KaiTi, serif;
|
||
font-size: clamp(19px, 35rpx, 24px);
|
||
font-weight: 700;
|
||
}
|
||
.article-state-card > text:nth-child(2) {
|
||
margin-top: 18rpx;
|
||
color: $ink-muted;
|
||
font-size: clamp(15px, 24rpx, 18px);
|
||
line-height: 1.65;
|
||
}
|
||
.article-state-card .app-button {
|
||
margin-top: 28rpx;
|
||
}
|
||
</style>
|