Files
Docker/uniapp/youjiala/src/pages/article/article.vue
T
2026-10-22 13:55:27 +08:00

505 lines
15 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.
<script setup lang="ts">
/**
* 有家文化传承「文章」详情页(短地址 /article):图2 只读态与图3 管理态同页两态。
* 入参经 query:name=<ArticleName> 打开某篇文章;mode=create 直接进新建态并弹底部编辑层。
* 数据链全在 composables/useArticles,本页只渲染 + 把点击转成调用;编辑复用 components/article-editor。
* 角色分权(后端 FindByKey 下发 IsOwner/IsManager/IsMember,前端只负责显隐):
* - 只读态(普通成员,图2):封面 + 标题 + 富文本正文;未审核非作者后端已拦,进不来这里;
* - 管理态(作者/群管理员,图3):顶部封面卡(编号 + 编辑/删除)+ 概览/图片/群组三段 Tab,
* 概览含「已审核 / 置顶」行内即时开关(改后立即 Update 落库)。
*/
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { briefForDisplay, useArticles, useThemeGradient, useThemeTokens } from "@/composables";
import { smartBack } from "@/router/routes";
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
const { t } = useLocale();
const { themeBackground } = useThemeGradient();
const { primary } = useThemeTokens();
const api = useArticles();
const { article, groups, filenames, loading, errorText, coverUrl } = api;
/** 底部编辑弹层可见(新建/编辑共用) */
const editorVisible = ref(false);
/** 管理态三段 Tab:概览 / 图片 / 群组 */
const activeTab = ref<"overview" | "images" | "groups">("overview");
/** 管理态:作者或任一关联群组管理员(图3);否则只读态(图2) */
const manageMode = computed(() => !!article.value && api.canManage.value);
async function openByQuery(name: string): Promise<void> {
await api.openArticle(name);
if (errorText.value) {
uni.showToast({ title: errorText.value, icon: "none" });
}
}
onLoad(async (query) => {
const mode = String(query?.mode || "");
const name = decodeURIComponent(String(query?.name || ""));
if (mode === "create") {
api.startCreate();
editorVisible.value = true;
return;
}
if (name) {
await openByQuery(name);
}
});
function onBack(): void {
smartBack();
}
function onEdit(): void {
api.startEdit();
editorVisible.value = true;
}
function onCloseEditor(): void {
editorVisible.value = false;
// 新建态取消:无落库直接退出;编辑态取消:丢弃本地改动重拉详情
if (api.creating.value) {
onBack();
return;
}
void api.cancelEdit();
}
async function onEditorSaved(): Promise<void> {
editorVisible.value = false;
if (article.value?.ArticleName) {
await api.openArticle(article.value.ArticleName);
}
}
function onApproveToggle(on: boolean): void {
void runFlag("ApprovedIndc", on);
}
function onTopToggle(on: boolean): void {
void runFlag("TopIndc", on);
}
async function runFlag(field: "ApprovedIndc" | "TopIndc", on: boolean): Promise<void> {
try {
await api.setFlag(field, on);
}
catch (error: any) {
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
// 失败回滚本地:重拉详情还原开关
if (article.value?.ArticleName) {
await api.openArticle(article.value.ArticleName);
}
}
}
function onDelete(): void {
uni.showModal({
title: t("article.deleteTitle"),
content: t("article.deleteConfirm"),
confirmColor: primary.value,
success: async (res) => {
if (!res.confirm) {
return;
}
try {
await api.remove();
onBack();
}
catch (error: any) {
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
}
},
});
}
function imageSrc(row: { ImageUrl: string; FileName: string }): string {
// 已保存态后端下发打包好的 ImageUrl;极端缺失时按落盘口径拼直链,不拼裸物理名坏地址
return absoluteFileUrl(row.ImageUrl) || backupFileUrl("ArticleImage", row.FileName);
}
/** 只读态正文:给插图烘上限宽内联样式(scoped 穿透不了 rich-text,只能改 HTML)并把内联 img src 归一到当前端可达地址(修 App 上正文插图不显示) */
const briefHtml = computed(() => briefForDisplay(article.value?.Brief));
</script>
<template>
<app-page :hide-nav="true" :show-tabbar="false">
<view class="article-page" :style="{ background: themeBackground }">
<view class="article-page__head">
<hero-navbar :title="t('article.title')" @back="onBack" />
</view>
<view v-if="loading" class="article-page__state">
<text class="article-page__state-text">{{ t("FilePage.loading") }}</text>
</view>
<view v-else-if="errorText && !article" class="article-page__state">
<text class="article-page__state-text article-page__state-text--error">{{ errorText }}</text>
</view>
<scroll-view v-else-if="article" scroll-y class="article-page__scroll">
<!-- ===== 管理态头卡(图3):顶部封面 + 下方编号行(编号 + 编辑/删除)===== -->
<template v-if="manageMode">
<view class="ac-hero">
<image v-if="coverUrl" class="ac-hero__cover" :src="coverUrl" mode="aspectFill" />
<view class="ac-hero__bar">
<view class="ac-hero__code">
<text class="ac-hero__code-text">{{ article.ArticleName }}</text>
</view>
<view class="ac-hero__acts">
<view class="ac-hero__act" @tap="onEdit">
<u-icon name="edit-pen" size="30" :color="primary" />
</view>
<view class="ac-hero__act ac-hero__act--del" @tap="onDelete">
<u-icon name="trash" size="30" color="#f0413e" />
</view>
</view>
</view>
</view>
<!-- 三段 Tab -->
<view class="ac-tabs">
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'overview' }"
@tap="activeTab = 'overview'">
<text>{{ t("article.tabOverview") }}</text>
</view>
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'images' }"
@tap="activeTab = 'images'">
<text>{{ t("article.tabImages") }}</text>
<text class="ac-tab__cnt">{{ filenames.length }}</text>
</view>
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'groups' }"
@tap="activeTab = 'groups'">
<text>{{ t("article.tabGroups") }}</text>
<text class="ac-tab__cnt">{{ groups.length }}</text>
</view>
</view>
<!-- 概览:标题 + 正文 + 已审核/置顶即时开关 -->
<view v-show="activeTab === 'overview'" class="ac-card">
<view class="ac-field ac-field--row">
<text class="ac-field__label">{{ t("article.fieldApproved") }}</text>
<u-switch :model-value="article.ApprovedIndc === 'Y'" :active-color="primary"
@change="(v: any) => onApproveToggle(!!v)" />
</view>
<view class="ac-field ac-field--row">
<text class="ac-field__label">{{ t("article.fieldTop") }}</text>
<u-switch :model-value="article.TopIndc === 'Y'" :active-color="primary"
@change="(v: any) => onTopToggle(!!v)" />
</view>
<view class="ac-divider" />
<text class="ac-title">{{ article.ArticleTitle || t("article.unnamed") }}</text>
<rich-text class="ac-body" :nodes="briefHtml" />
</view>
<!-- 图片:正文配图明细 -->
<view v-show="activeTab === 'images'" class="ac-card">
<view v-if="!filenames.length" class="ac-empty"><text>{{ t("article.noImage") }}</text></view>
<view v-else class="ac-images">
<image v-for="img in filenames" :key="img.FileName" class="ac-images__item"
:src="imageSrc(img)" mode="aspectFill" />
</view>
</view>
<!-- 群组:关联族谱 -->
<view v-show="activeTab === 'groups'" class="ac-card">
<view v-if="!groups.length" class="ac-empty"><text>{{ t("article.noGroup") }}</text></view>
<view v-for="g in groups" :key="g.FamilyName" class="ac-group">
<text class="ac-group__name">{{ g.FamilyLocalName || g.FamilyName }}</text>
<text class="ac-group__code">{{ g.FamilyName }}</text>
</view>
</view>
</template>
<!-- ===== 只读态(图2):普通成员视图,顶部封面 + 标题 + 作者/日期 + 正文 ===== -->
<template v-else>
<view class="ar-body">
<image v-if="coverUrl" class="ar-cover" :src="coverUrl" mode="aspectFill" />
<text class="ar-title">{{ article.ArticleTitle || t("article.unnamed") }}</text>
<view class="ar-meta">
<image v-if="article.AuthorAvatarUrl" class="ar-meta__avatar"
:src="absoluteFileUrl(article.AuthorAvatarUrl)" mode="aspectFill" />
<text class="ar-meta__author">{{ article.AuthorLocalName || t("article.unknownAuthor")
}}</text>
<text class="ar-meta__date">{{ api.formatDate(article.CreatedDatetime) }}</text>
</view>
<view class="ar-divider" />
<rich-text class="ar-content" :nodes="briefHtml" />
</view>
</template>
<view class="article-page__space" />
</scroll-view>
<!-- 底部编辑弹层(新建 / 编辑复用) -->
<article-editor :visible="editorVisible" :api="api" @close="onCloseEditor" @saved="onEditorSaved" />
</view>
</app-page>
</template>
<style lang="scss" scoped>
.article-page {
display: flex;
flex-direction: column;
height: 100vh;
}
.article-page__head {
flex: none;
}
.article-page__scroll {
/* box-sizing 必须 border-box:否则 padding 24rpx 会加到 100% 宽之外,
右侧内边距溢出屏外的,把日期、正文插图右缘同宽裁掉(管理态/只读态同一滚动容器都受影响) */
box-sizing: border-box;
flex: 1;
min-height: 0;
padding: 24rpx;
overflow-x: hidden;
}
.article-page__state {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 120rpx 32rpx;
}
.article-page__state-text {
font-size: 26rpx;
color: #9aa0a6;
}
.article-page__state-text--error {
color: #f0413e;
}
.article-page__space {
height: 48rpx;
}
/* ===== 管理态头卡 ===== */
.ac-hero {
overflow: hidden;
border-radius: 28rpx;
background-color: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.06);
}
.ac-hero__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 28rpx;
}
/* 管理态头卡封面:整宽嵌在卡内,ac-hero 已 overflow:hidden + 圆角,图自动被裁成下圆角卡面 */
.ac-hero__cover {
display: block;
width: 100%;
height: 320rpx;
background-color: #f2f3f5;
}
.ac-hero__code-text {
font-size: 24rpx;
color: #8f959e;
}
.ac-hero__acts {
display: flex;
gap: 20rpx;
}
.ac-hero__act {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: var(--fg-soft, #eaf4ff);
}
.ac-hero__act--del {
background-color: #fdeceb;
}
/* ===== 三段 Tab ===== */
.ac-tabs {
display: flex;
gap: 16rpx;
margin: 24rpx 0;
}
.ac-tab {
display: flex;
align-items: center;
gap: 8rpx;
padding: 14rpx 28rpx;
font-size: 26rpx;
color: #5c626b;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 999rpx;
}
.ac-tab--on {
color: #ffffff;
background: var(--fg-gradient, #ff9d45);
}
.ac-tab__cnt {
font-size: 22rpx;
}
.ac-card {
padding: 28rpx;
border-radius: 28rpx;
background-color: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.06);
}
.ac-field {
display: flex;
flex-direction: column;
gap: 10rpx;
padding: 12rpx 0;
}
.ac-field--row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.ac-field__label {
font-size: 28rpx;
color: #1f2329;
}
.ac-divider {
height: 1rpx;
margin: 20rpx 0;
background-color: #eceef1;
}
.ac-title {
display: block;
margin: 8rpx 0 20rpx;
font-size: 40rpx;
font-weight: 700;
color: #1f2329;
}
.ac-body {
font-size: 28rpx;
line-height: 46rpx;
color: #3d4149;
}
.ac-empty {
padding: 60rpx 0;
font-size: 26rpx;
color: #9aa0a6;
text-align: center;
}
.ac-images {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.ac-images__item {
width: 200rpx;
height: 200rpx;
border-radius: 16rpx;
}
.ac-group {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f1f3;
}
.ac-group:last-child {
border-bottom: none;
}
.ac-group__name {
font-size: 28rpx;
color: #1f2329;
}
.ac-group__code {
font-size: 22rpx;
color: #bfc3c9;
}
/* ===== 只读态(今日头条式通栏直排:无卡片包裹,标题/作者/正文直接顺排)===== */
.ar-body {
padding: 8rpx 0 0;
}
/* 只读态顶部封面:自带圆角与背景占位,与正文同宽(受 scroll 24rpx 内边距约束) */
.ar-cover {
display: block;
width: 100%;
height: 360rpx;
margin-bottom: 24rpx;
border-radius: 20rpx;
background-color: #f2f3f5;
}
.ar-title {
display: block;
font-size: 42rpx;
font-weight: 700;
line-height: 60rpx;
color: #1f2329;
}
.ar-meta {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 20rpx;
}
.ar-meta__avatar {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background-color: #f2f3f5;
}
.ar-meta__author {
font-size: 24rpx;
color: #5c626b;
}
.ar-meta__date {
margin-left: auto;
font-size: 22rpx;
color: #bfc3c9;
}
.ar-divider {
height: 1rpx;
margin: 24rpx 0;
background-color: #eceef1;
}
.ar-content {
font-size: 30rpx;
line-height: 52rpx;
color: #1f2329;
}
</style>