Files
jiapuapp/pages/family/article-detail.vue
T

521 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<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 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>