This commit is contained in:
owenchen committed 2026-10-22 13:55:27 +08:00
1 parent c6eb00b406
commit 1c91f62ff6
58 files changed
+3888 -69

No files matched your search

@@ -0,0 +1,504 @@
<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>