article
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user