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,621 @@
<script setup lang="ts">
/**
* 有家文化传承「文章」底部编辑弹层(新建 / 编辑复用,图3 的修改入口)。
* 受控组件:只渲染 + 交互,数据整包托管给父级传入的 useArticles 实例(props.api.detail),
* 标题/正文/封面/群组/配图全部本地改整包,保存读富文本 html 回写 Brief 后 api.save() 落库。
* 契约(对齐相册编辑弹层 + 后端 UploadFolderPolicy):
* 1. 封面走 /DmsFile/UploadFile,FilePath 头 = "ArticleCover";DB 存分片物理名 ArticleCoverFilename;
* 2. 正文配图同样走 /DmsFile/UploadFile,FilePath 头 = "ArticleImage",物理名进 article_filename 明细;
* 3. 富文本用 uni-app 内置 <editor>(H5 + App webview):ready 取 editorCtx,打开时 setContents 灌 Brief,
* 保存前 getContents 取回 html;插入图片后同步登记配图明细;
* 4. 群组候选复用 useFamilyGroups(我可见的群),增删只改本地整包,保存由后端按 FamilyName 差量落库。
*/
import type { useArticles } from "@/composables/useArticles";
import { useLocale } from "uview-pro";
import { computed, getCurrentInstance, ref, watch } from "vue";
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
import { useFamilyGroups } from "@/composables";
import { briefForDisplay, briefForStore } from "@/composables/useArticles";
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
type ArticleApi = ReturnType<typeof useArticles>;
const props = defineProps<{
visible: boolean;
api: ArticleApi;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "saved"): void;
}>();
const { t } = useLocale();
/** 与后端 UploadFolderPolicy 同名的落盘目录(上传 FilePath 头 / 保存前预览打包地址共用) */
const COVER_FOLDER = "ArticleCover";
const IMAGE_FOLDER = "ArticleImage";
const familyGroups = useFamilyGroups();
// 整包托管:直接引用 api 的 computed/ref,改动即反映到 api.detail
const article = props.api.article;
const groups = props.api.groups;
const filenames = props.api.filenames;
const saving = ref(false);
const coverUploading = ref(false);
/** 封面预览:新上传用本地临时地址兜底,编辑态用后端打包的 CoverUrl */
const coverPreview = ref("");
// ===== 富文本编辑器 =====
let editorCtx: any = null;
const editorReady = ref(false);
/** 弹层在 editor ready 前就要求灌内容时暂存,ready 后补写 */
let pendingHtml: string | null = null;
const editorInstance = getCurrentInstance();
/** 当前应灌进编辑器的正文(限宽样式烘入 + img src 归一到当前端可达绝对地址) */
function currentHtml(): string {
return briefForDisplay(article.value?.Brief);
}
/**
* 拿到 editor 上下文后统一收尾:置就绪 + 灌入正文。
* u-popup 内容是 v-if,弹层每次重开都会重建 <editor>:旧 ctx 已失效,
* 新实例 ready 时只要弹层开着就重取最新 Brief 补灌(不依赖 pendingHtml 是否被消费)。
*/
function bindEditorContext(ctx: any): void {
if (!ctx) {
return;
}
editorCtx = ctx;
editorReady.value = true;
if (props.visible && pendingHtml === null) {
pendingHtml = currentHtml();
}
if (pendingHtml !== null) {
editorCtx.setContents({ html: pendingHtml });
pendingHtml = null;
}
}
/**
* 取 uni-app <editor> 上下文的跨端正确写法:createSelectorQuery().select().context()。
* H5/App 端 ready 事件的 e.detail.editor 可能为 undefined(曾导致正文读写全废),
* 故以 selector query 为准,e.detail.editor 仅作兜底;小程序端需 .in(组件实例) 限定作用域。
*/
function onEditorReady(e?: any): void {
const hasCtx = (c: any): boolean => !!c && typeof c.setContents === "function";
if (hasCtx(e?.detail?.editor)) {
bindEditorContext(e.detail.editor);
return;
}
const query = editorInstance
? uni.createSelectorQuery().in(editorInstance.proxy)
: uni.createSelectorQuery();
query.select("#article-editor")
.context((res: any) => bindEditorContext(res?.context))
.exec();
}
function format(key: string): void {
editorCtx?.format(key, {});
}
/** 取回当前富文本 html(getContents 异步,包一层 Promise 供保存串行使用)。
* 编辑器未就绪或读取失败时兑当前 Brief,绝不可返回空串——否则一次失败就把正文洗掉 */
function readHtml(): Promise<string> {
if (!editorReady.value || !editorCtx) {
return Promise.resolve(String(article.value?.Brief ?? ""));
}
return new Promise((resolve) => {
editorCtx.getContents({
success: (res: any) => resolve(res.html || String(article.value?.Brief ?? "")),
fail: () => resolve(String(article.value?.Brief ?? "")),
});
});
}
/** 打开时把整包 Brief 灌进编辑器;关闭不重置,便于连续编辑 */
watch(
() => props.visible,
(open) => {
if (!open) {
return;
}
// 灌入前给插图烘上限宽样式:编辑器内也按屏宽显示,且样式随保存回写进 Brief
pendingHtml = currentHtml();
// 封面预览:后端已保存态用打包的 CoverUrl;未保存的新上传按落盘口径拼直链(上传即落盘,保存前就能取到图)
coverPreview.value = article.value?.CoverUrl
? absoluteFileUrl(article.value.CoverUrl)
: backupFileUrl(COVER_FOLDER, article.value?.ArticleCoverFilename);
// 旧 ctx 可能指向上次弹层里已销毁的 editor:打不进去就留给新实例 ready 时补灌
if (editorReady.value && editorCtx) {
try {
editorCtx.setContents({ html: pendingHtml });
pendingHtml = null;
}
catch {
/* 弹层重建了 editor,等 @ready 重新取上下文后补写 */
}
}
},
{ immediate: true },
);
// ===== 封面上传 =====
function onPickCover(): void {
if (coverUploading.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `cover-${Date.now()}.png`);
if (!filePath || !article.value) {
return;
}
try {
coverUploading.value = true;
const physicalName = buildChunkFileName(fileName);
await uploadFile({
url: "/DmsFile/UploadFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: COVER_FOLDER },
});
article.value.ArticleCoverFilename = physicalName;
coverPreview.value = filePath;
}
catch (error: any) {
uni.showToast({ title: error?.message || t("article.uploadFailed"), icon: "none" });
}
finally {
coverUploading.value = false;
}
},
});
}
// ===== 正文配图:上传 + 编辑器内联 + 登记明细 =====
function onInsertImage(): void {
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `img-${Date.now()}.png`);
if (!filePath) {
return;
}
try {
const physicalName = buildChunkFileName(fileName);
await uploadFile({
url: "/DmsFile/UploadFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: IMAGE_FOLDER },
});
props.api.addImage(physicalName);
// 内联 src 用落盘直链(非裸物理名):保存前可见,且这个地址会烘进 Brief,保存后仍有效
editorCtx?.insertImage({ src: backupFileUrl(IMAGE_FOLDER, physicalName), alt: fileName }, () => { });
}
catch (error: any) {
uni.showToast({ title: error?.message || t("article.uploadFailed"), icon: "none" });
}
},
});
}
function onRemoveImage(physicalName: string): void {
props.api.removeImage(physicalName);
}
/** 复用已上传配图:点缩略图把图重新插回正文(不重复上传),插入位置跟随编辑器最后光标 */
function onInsertExisting(img: { ImageUrl: string; FileName: string }): void {
const src = absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName);
if (!src) {
return;
}
if (!editorReady.value || !editorCtx) {
uni.showToast({ title: t("article.editorNotReady"), icon: "none" });
return;
}
editorCtx.insertImage({ src, alt: img.FileName }, () => {
uni.showToast({ title: t("article.imageInserted"), icon: "none" });
});
}
// ===== 群组选择器(复用共享数据源,ActionSheet 选一个未关联的群) =====
async function onAddGroup(): Promise<void> {
await familyGroups.loadList();
const candidates = familyGroups.groups.value.filter(
g => !groups.value.some(row => row.FamilyName === g.FamilyName),
);
if (!candidates.length) {
uni.showToast({ title: t("article.noGroupCandidate"), icon: "none" });
return;
}
uni.showActionSheet({
itemList: candidates.map(g => g.FamilyLocalName || g.FamilyName),
success: (res) => {
const pick = candidates[res.tapIndex];
props.api.addGroup(pick.FamilyName, pick.FamilyLocalName || "");
},
});
}
function onRemoveGroup(familyName: string): void {
props.api.removeGroup(familyName);
}
// ===== 保存 / 取消 =====
async function onSave(): Promise<void> {
if (!article.value) {
return;
}
if (!(article.value.ArticleTitle || "").trim()) {
uni.showToast({ title: t("article.titleRequired"), icon: "none" });
return;
}
saving.value = true;
try {
// 保存前烘限宽样式并把 img src 剥成 host 无关相对路径:新插入/复用的图也带约束、且落库跨环境可移植
article.value.Brief = briefForStore(await readHtml());
await props.api.save();
emit("saved");
}
catch (error: any) {
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
}
finally {
saving.value = false;
}
}
const imageCount = computed(() => filenames.value.length);
/** 标题双算子:读写透传到整包 article.ArticleTitle,避开模板对可空 computed 的非空断言 */
const title = computed({
get: () => article.value?.ArticleTitle ?? "",
set: (val: string) => {
if (article.value) {
article.value.ArticleTitle = val;
}
},
});
</script>
<template>
<u-popup :model-value="visible" mode="bottom" round="24" @close="emit('close')">
<view class="article-editor">
<view class="article-editor__head">
<text class="article-editor__title">{{ t("article.editTitle") }}</text>
<u-icon name="close" size="32" color="#8f959e" @click="emit('close')" />
</view>
<scroll-view scroll-y class="article-editor__scroll">
<view class="article-editor__form">
<!-- 标题 -->
<view class="ae-field">
<text class="ae-field__label">{{ t("article.fieldTitle") }}</text>
<input v-model="title" class="ae-field__input" :maxlength="100"
:placeholder="t('article.titleRequired')">
</view>
<!-- 封面 -->
<view class="ae-field">
<text class="ae-field__label">{{ t("article.fieldCover") }}</text>
<view class="ae-cover" @tap="onPickCover">
<image v-if="coverPreview" class="ae-cover__img" :src="coverPreview" mode="aspectFill" />
<view v-else class="ae-cover__ph">
<u-icon name="camera-fill" size="40" color="#c0c4cc" />
<text class="ae-cover__ph-text">{{ t("article.coverPick") }}</text>
</view>
</view>
</view>
<!-- 正文富文本 -->
<view class="ae-field">
<text class="ae-field__label">{{ t("article.fieldContent") }}</text>
<view class="ae-toolbar">
<text class="ae-toolbar__btn" @tap="format('bold')"><text
class="ae-toolbar__b">B</text></text>
<text class="ae-toolbar__btn" @tap="format('italic')"><text
class="ae-toolbar__i">I</text></text>
<text class="ae-toolbar__btn" @tap="format('underline')"><text
class="ae-toolbar__u">U</text></text>
<text class="ae-toolbar__btn" @tap="format('header')">{{ t("article.fmtHeader") }}</text>
<text class="ae-toolbar__btn" @tap="format('insertOrderedList')">{{ t("article.fmtList")
}}</text>
<text class="ae-toolbar__btn" @tap="onInsertImage">{{ t("article.fmtImage") }}</text>
</view>
<editor id="article-editor" class="ae-editor" :read-only="false" placeholder=""
@ready="onEditorReady" />
</view>
<!-- 配图明细 -->
<view class="ae-field">
<view class="ae-field__rowhead">
<text class="ae-field__label">{{ t("article.fieldImages") }}({{ imageCount }})</text>
<text class="ae-field__add" @tap="onInsertImage">+ {{ t("article.addImage") }}</text>
</view>
<view v-if="!imageCount" class="ae-empty"><text>{{ t("article.noImage") }}</text></view>
<template v-else>
<text class="ae-images__tip">{{ t("article.imageTapHint") }}</text>
<scroll-view scroll-x class="ae-images">
<view v-for="img in filenames" :key="img.FileName" class="ae-images__item">
<image class="ae-images__img" mode="aspectFill"
:src="absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName)"
@tap="onInsertExisting(img)" />
<view class="ae-images__del" @tap.stop="onRemoveImage(img.FileName)">
<u-icon name="close" size="20" color="#ffffff" />
</view>
</view>
</scroll-view>
</template>
</view>
<!-- 关联群组 -->
<view class="ae-field">
<view class="ae-field__rowhead">
<text class="ae-field__label">{{ t("article.fieldGroups") }}</text>
<text class="ae-field__add" @tap="onAddGroup">+ {{ t("article.addGroup") }}</text>
</view>
<view v-if="!groups.length" class="ae-empty"><text>{{ t("article.noGroup") }}</text></view>
<view v-for="g in groups" :key="g.FamilyName" class="ae-group">
<text class="ae-group__name">{{ g.FamilyLocalName || g.FamilyName }}</text>
<text class="ae-group__del" @tap="onRemoveGroup(g.FamilyName)">{{ t("article.delete")
}}</text>
</view>
</view>
</view>
</scroll-view>
<view class="article-editor__foot">
<view class="article-editor__btn" @tap="emit('close')">
<text>{{ t("article.cancel") }}</text>
</view>
<view class="article-editor__btn article-editor__btn--primary" :class="{ 'is-busy': saving }"
@tap="onSave">
<text>{{ saving ? t("FilePage.loading") : t("article.save") }}</text>
</view>
</view>
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.article-editor {
display: flex;
flex-direction: column;
max-height: 86vh;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
}
.article-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.article-editor__title {
font-size: 34rpx;
font-weight: 700;
color: #1f2329;
}
.article-editor__scroll {
/* 明确封顶高度:scroll-view 在 uni-app/H5 需确定高度才会内部滚动,
否则内容(尤其加配图后)撑满把底部取消/保存顶出可视区(同 person-editor / option-picker) */
flex: 1;
min-height: 0;
max-height: 60vh;
}
.article-editor__form {
display: flex;
flex-direction: column;
gap: 28rpx;
}
.ae-field {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.ae-field__label {
font-size: 26rpx;
color: #5c626b;
}
.ae-field__rowhead {
display: flex;
align-items: center;
justify-content: space-between;
}
.ae-field__add {
font-size: 24rpx;
color: var(--fg-primary, #2b8fff);
}
.ae-field__input {
height: 76rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #1f2329;
background-color: #f5f6f8;
border-radius: 16rpx;
}
.ae-cover {
width: 220rpx;
height: 150rpx;
overflow: hidden;
border-radius: 16rpx;
background-color: #f5f6f8;
}
.ae-cover__img {
width: 100%;
height: 100%;
}
.ae-cover__ph {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
gap: 8rpx;
}
.ae-cover__ph-text {
font-size: 22rpx;
color: #bfc3c9;
}
.ae-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
padding: 12rpx;
background-color: #f5f6f8;
border-radius: 16rpx 16rpx 0 0;
}
.ae-toolbar__btn {
min-width: 56rpx;
padding: 6rpx 16rpx;
font-size: 26rpx;
color: #5c626b;
text-align: center;
background-color: #ffffff;
border-radius: 10rpx;
}
.ae-toolbar__b {
font-weight: 700;
}
.ae-toolbar__i {
font-style: italic;
}
.ae-toolbar__u {
text-decoration: underline;
}
.ae-editor {
min-height: 560rpx;
padding: 20rpx;
font-size: 28rpx;
line-height: 44rpx;
color: #1f2329;
background-color: #fafbfc;
border: 1rpx solid #eceef1;
border-radius: 0 0 16rpx 16rpx;
}
.ae-empty {
padding: 28rpx;
font-size: 24rpx;
color: #9aa0a6;
text-align: center;
background-color: #f7f8fa;
border-radius: 16rpx;
}
.ae-images {
white-space: nowrap;
}
.ae-images__tip {
font-size: 22rpx;
color: #9aa0a6;
}
.ae-images__item {
position: relative;
display: inline-block;
width: 140rpx;
height: 140rpx;
margin-right: 16rpx;
overflow: hidden;
border-radius: 16rpx;
}
.ae-images__img {
width: 100%;
height: 100%;
}
.ae-images__del {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
background-color: rgba(0, 0, 0, 0.45);
border-radius: 0 0 0 16rpx;
}
.ae-group {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f7f8fa;
border-radius: 16rpx;
}
.ae-group__name {
font-size: 28rpx;
color: #1f2329;
}
.ae-group__del {
font-size: 24rpx;
color: #f0413e;
}
.article-editor__foot {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
}
.article-editor__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 30rpx;
color: #5c626b;
background-color: #f2f3f5;
border-radius: 999rpx;
}
.article-editor__btn--primary {
color: #ffffff;
background: var(--fg-gradient, #ff9d45);
}
.article-editor__btn--primary.is-busy {
opacity: 0.6;
}
</style>
@@ -20,6 +20,7 @@ import { brandGlyph } from "@/common/brand";
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
import { userTypeGlyph, userTypePillBg } from "@/common/userTypeIcons";
import { useIdCardVerify, useThemeGradient, useThemeTokens } from "@/composables";
import { useArticles } from "@/composables/useArticles";
import { formatFileSize, formatFileTime, useFamilyTreeExchange } from "@/composables/useFamilyTreeExchange";
import { usePersonPickConfig } from "@/composables/usePickerConfigs";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
@@ -37,8 +38,9 @@ import familyPersonService from "@/service/master/familyPersonService";
import familyTreeService from "@/service/master/familyTreeService";
import dmsFileService from "@/service/master/dmsFileService";
import { useLoginProfileStore } from "@/stores";
import { pageUrl } from "@/router/routes";
import { chooseFiles } from "@/utils/chooseFiles";
import { absoluteFileUrl } from "@/utils/fileUrl";
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
/** ready:组件挂载完成通知(时序口径见文件底部 onMounted 注释) */
const emit = defineEmits<{ (e: "ready"): void }>();
@@ -239,6 +241,60 @@ async function onRetry(): Promise<void> {
await loadTrees();
}
// ===== 有家文化传承(文章)卡片:与族谱列表并列,数据源见 composables/useArticles =====
const articlesApi = useArticles();
const articleKeyword = ref("");
const showArticleSearch = ref(false);
const articleCollapsed = ref(false);
/** 首页「+」新建弹层可见(编辑层不另开页,直接底部弹出,同相册/群编辑) */
const articleEditorVisible = ref(false);
/** 首页卡只展示最新 5 条(列表已按置顶、创建时间排序) */
const homeArticles = computed(() => articlesApi.list.value.slice(0, 5));
/** 无封面时的书卷字形:内联 SVG(同 treeGlyph 套路),白描卷轴 */
const articleGlyph = `data:image/svg+xml,${encodeURIComponent([
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='#ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'>",
"<path d='M10 12c0-2 1.6-3.6 3.6-3.6H30v30H13.6C11.6 38.4 10 36.8 10 34.8Z'/>",
"<path d='M30 8.4h6.4c2 0 3.6 1.6 3.6 3.6v22.8c0 2-1.6 3.6-3.6 3.6H30'/>",
"<path d='M15 16h10M15 22h10'/>",
"</svg>",
].join(""))}`;
async function loadArticles(): Promise<void> {
await articlesApi.loadList();
}
function onToggleArticleSearch(): void {
showArticleSearch.value = !showArticleSearch.value;
}
async function onArticleSearch(): Promise<void> {
await articlesApi.loadList(articleKeyword.value);
}
function onCreateArticle(): void {
// 首页「+」直接弹底部编辑层(不另开页):新建空白整包,保存走 useArticles.save
articlesApi.startCreate();
articleEditorVisible.value = true;
}
function closeArticleEditor(): void {
articleEditorVisible.value = false;
void articlesApi.cancelEdit();
}
async function onArticleSaved(): Promise<void> {
articleEditorVisible.value = false;
await loadArticles();
}
function onOpenArticle(name: string): void {
if (!name) {
return;
}
uni.navigateTo({ url: `${pageUrl("articlePage")}?name=${encodeURIComponent(name)}` });
}
// ===== 详情态数据(原 family-tree-operation 全量照搬) =====
const data = ref<FamilyTreeData>(new FamilyTreeData());
const tree = computed(() => data.value.FamilyTree);
@@ -1520,6 +1576,7 @@ defineExpose({
reloadIfList(): void {
if (!inDetail.value) {
loadTrees();
void loadArticles();
void fetchStorage();
}
},
@@ -1528,7 +1585,8 @@ defineExpose({
<template>
<!-- 列表态显示 tabbar,进详情(或弹层展开)收起;未认证底部弹层也在列表态弹出,须一并隐 tabbar,否则盖住底部按钮 -->
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup">
<!-- 文章底部编辑层同理:不隐 tabbar 会盖住弹层底部的「取消/保存」(tabbar 与弹层不在同一层叠上下文,z-index 压不过) -->
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup && !articleEditorVisible">
<view class="tree-page" :style="[{ background: themeBackground }, themeVars]">
<!-- ===== 列表态:软底分区面板(标题 + 搜索/新建/伸缩圆钮组 + 白底族谱卡) ===== -->
<!-- 隐藏原生滚动条:否则 Chrome 滚动条占右侧 ~17px,面板左右留白不对称 -->
@@ -1569,7 +1627,7 @@ defineExpose({
<text class="home-card__stitle">{{ t("storage.title") }}</text>
</view>
<text class="home-card__spct" :class="{ 'home-card__spct--over': storageOver }">{{ storagePercentText
}}</text>
}}</text>
</view>
<view class="home-card__bar">
<view class="home-card__bar-fill" :class="{ 'home-card__bar-fill--over': storageOver }"
@@ -1634,6 +1692,69 @@ defineExpose({
</template>
</view>
</view>
<!-- ===== 有家文化传承:与族谱面板并列的文章卡 ===== -->
<view class="tree-panel article-panel">
<view class="tree-panel__head">
<text class="tree-panel__title">{{ t("article.title") }}</text>
<view class="tree-panel__acts">
<view class="tree-panel__act" @tap="onToggleArticleSearch">
<u-icon name="search" size="34" :color="primary" />
</view>
<view class="tree-panel__act tree-panel__act--add" @tap="onCreateArticle">
<u-icon name="plus" size="30" color="#ffffff" />
</view>
<view class="tree-panel__act" @tap="articleCollapsed = !articleCollapsed">
<u-icon :name="articleCollapsed ? 'arrow-up' : 'arrow-down'" size="34" :color="primary" />
</view>
</view>
</view>
<view v-if="showArticleSearch" class="tree-panel__search">
<search-input v-model="articleKeyword" :placeholder="t('article.searchPlaceholder')"
@search="onArticleSearch" />
</view>
<view v-if="!articleCollapsed" class="tree-panel__list">
<view v-if="articlesApi.loading.value" class="tree-page__state">
<text class="tree-page__state-text">{{ t("FilePage.loading") }}</text>
</view>
<view v-else-if="articlesApi.errorText.value" class="tree-page__state">
<text class="tree-page__state-text tree-page__state-text--error">{{ articlesApi.errorText.value }}</text>
<text class="tree-page__retry" @click="loadArticles">{{ t("FilePage.retryBut") }}</text>
</view>
<template v-else>
<view v-if="!homeArticles.length" class="tree-page__empty">
<text class="tree-page__state-text">{{ t("article.empty") }}</text>
</view>
<view v-for="row in homeArticles" :key="row.ArticleName" class="article-card"
@tap="onOpenArticle(row.ArticleName)">
<view class="article-card__cover">
<image v-if="row.CoverUrl || row.ArticleCoverFilename" class="article-card__cover-img"
:src="absoluteFileUrl(row.CoverUrl) || backupFileUrl('ArticleCover', row.ArticleCoverFilename)"
mode="aspectFill" />
<image v-else class="article-card__glyph" :src="articleGlyph" mode="aspectFit" />
</view>
<view class="article-card__main">
<view class="article-card__titlerow">
<text class="article-card__name">{{ row.ArticleTitle || t("article.unnamed") }}</text>
<text v-if="row.TopIndc === 'Y'" class="article-card__chip article-card__chip--top">{{
t("article.tagTop") }}</text>
<text v-if="row.ApprovedIndc !== 'Y'" class="article-card__chip article-card__chip--pending">{{
t("article.tagPending") }}</text>
</view>
<text class="article-card__desc">{{ articlesApi.plainText(row.Brief, 100) || t("article.noBrief")
}}</text>
<view class="article-card__meta">
<image v-if="row.AuthorAvatarUrl" class="article-card__avatar"
:src="absoluteFileUrl(row.AuthorAvatarUrl)" mode="aspectFill" />
<view v-else class="article-card__avatar article-card__avatar--empty" />
<text class="article-card__author">{{ row.AuthorLocalName || t("article.unknownAuthor") }}</text>
<text class="article-card__date">{{ articlesApi.formatDate(row.CreatedDatetime) }}</text>
</view>
</view>
<u-icon name="arrow-right" size="30" color="#c0c4cc" />
</view>
</template>
</view>
</view>
</scroll-view>
<!-- ===== 详情态:族谱管理 ===== -->
@@ -1665,7 +1786,7 @@ defineExpose({
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40"
:active-color="primary" @change="onPublicToggle" />
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag")
}}</text>
}}</text>
</view>
</view>
</view>
@@ -1772,7 +1893,7 @@ defineExpose({
<!-- 当前族员:从概览移入,夫妻/子女列表都是相对此人 -->
<view v-if="loaded && currentPerson" class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson")
}}</text></view>
}}</text></view>
<view class="person-hero">
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
@@ -1787,7 +1908,7 @@ defineExpose({
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson)
}}</text>
}}</text>
</view>
</view>
</view>
@@ -1830,12 +1951,12 @@ defineExpose({
<!-- 新增入口与行尾操作同一把闸:当前锚点不可编(未绑定/出支系)时隐藏 -->
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle")
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view>
</view>
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty")
}}</text></view>
}}</text></view>
<view v-for="record in visibleSpouses" :key="record.SpouseId" class="ft-mrow">
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
@@ -1871,13 +1992,13 @@ defineExpose({
<view class="ft-card">
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle")
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text>
</view>
</view>
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty")
}}</text></view>
}}</text></view>
<view v-for="record in visibleChildren" :key="record.ChildrenId" class="ft-mrow">
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
@@ -1917,7 +2038,7 @@ defineExpose({
<view v-if="data.IsManager && pendingPermissions.length" class="ft-card">
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending")
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
</view>
<view v-for="record in pendingPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
@@ -1941,10 +2062,10 @@ defineExpose({
<view v-if="data.IsManager" class="ft-card">
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager")
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
</view>
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty")
}}</text></view>
}}</text></view>
<view v-for="record in managerPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
record.UserName
@@ -2179,7 +2300,7 @@ defineExpose({
<view class="person-hero__line">
<text class="rel-name">{{ record.SpouseSurname }}{{ record.SpouseGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.SpouseId, record.Gender))
}}</text>
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
@@ -2216,7 +2337,7 @@ defineExpose({
<text class="sub-label">{{ t("FamilyTreePage.marryDate") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{ datePart(record.MarryDate) || t("AdminPage.notSetTip")
}}</text>
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
@@ -2232,7 +2353,7 @@ defineExpose({
<view class="sub-picker">
<text class="sub-picker-text">{{
datePart(record.DivorceDate) || t("AdminPage.notSetTip")
}}</text>
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
@@ -2318,7 +2439,7 @@ defineExpose({
<view class="person-hero__line">
<text class="rel-name">{{ record.ChildrenSurname }}{{ record.ChildrenGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.ChildrenId, record.Gender))
}}</text>
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
@@ -2429,7 +2550,7 @@ defineExpose({
<text class="rel-name">{{ record.UserLocalName || record.UserName }}</text>
<text class="rel-sub">{{
record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag")
}}</text>
}}</text>
</view>
<view class="perm-ops">
<view class="perm-op perm-op--danger" @tap="rejectPermission(record)">
@@ -2606,7 +2727,7 @@ defineExpose({
<!-- 服务器文件列表限高滚动:文件多了不挤掉密码框/警告/按钮 -->
<scroll-view v-if="!localFileName" scroll-y class="ft-exchange__list">
<view v-if="importFilesLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading")
}}</text></view>
}}</text></view>
<view v-else-if="!importFiles.length" class="ft-empty"><text>{{ t(imScope === "SUB" ?
"FamilyTreePage.exchangeFileEmptySub" : "FamilyTreePage.exchangeFileEmpty") }}</text></view>
<view v-for="file in importFiles" :key="file.FileName" class="ft-exchange__row"
@@ -2627,7 +2748,7 @@ defineExpose({
<view v-if="backupLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading") }}</text>
</view>
<view v-else-if="!backupFiles.length" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeBackupEmpty")
}}</text></view>
}}</text></view>
<view v-for="bk in backupFiles" :key="bk.FileName" class="ft-exchange__row"
:class="{ 'ft-exchange__row--on': pickedBackup === bk.FileName }" @tap="pickedBackup = bk.FileName">
<view class="ft-exchange__row-main">
@@ -2693,11 +2814,15 @@ defineExpose({
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="cancelTreeEdit"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut")
}}</text>
}}</text>
</view>
</view>
</view>
</u-popup>
<!-- 有家文化传承:首页「+」新建底部编辑层(不另开页) -->
<article-editor :visible="articleEditorVisible" :api="articlesApi" @close="closeArticleEditor"
@saved="onArticleSaved" />
</view>
</app-page>
</template>
@@ -3264,6 +3389,127 @@ defineExpose({
color: #bfc3c9;
}
/* ===== 有家文化传承文章卡(复用 tree-panel 面板几何,卡体换横向图文) ===== */
.article-card {
display: flex;
align-items: center;
gap: 20rpx;
margin: 0 0 20rpx;
padding: 24rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05);
}
.article-card:last-child {
margin-bottom: 0;
}
.article-card__cover {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 128rpx;
height: 128rpx;
overflow: hidden;
border-radius: 20rpx;
background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff));
}
.article-card__cover-img {
width: 100%;
height: 100%;
}
.article-card__glyph {
width: 60rpx;
height: 60rpx;
}
.article-card__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 8rpx;
}
.article-card__titlerow {
display: flex;
align-items: center;
gap: 10rpx;
}
.article-card__name {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.article-card__chip {
flex: none;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 20rpx;
color: #5c626b;
background-color: #f2f3f5;
}
.article-card__chip--top {
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
}
.article-card__chip--pending {
color: #d48806;
background-color: #fff7e6;
}
.article-card__desc {
font-size: 24rpx;
line-height: 34rpx;
color: #8f959e;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.article-card__meta {
display: flex;
align-items: center;
gap: 10rpx;
}
.article-card__avatar {
flex: none;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background-color: #f2f3f5;
}
.article-card__avatar--empty {
background: var(--fg-soft, #eaf4ff);
}
.article-card__author {
font-size: 22rpx;
color: #5c626b;
}
.article-card__date {
margin-left: auto;
font-size: 20rpx;
color: #bfc3c9;
}
/* ===== 详情头卡 + 圆形动作钮(与共享页同套) ===== */
.detail-hero {
position: relative;
+1
View File
@@ -1,6 +1,7 @@
export * from "./useAdminList";
export * from "./useAdminMenu";
export * from "./useAdminPick";
export * from "./useArticles";
export * from "./useAuthGuard";
export * from "./useDmsFiles";
export * from "./useFamilyGroups";
@@ -0,0 +1,371 @@
/**
* 有家文化传承「文章」数据源:列表 / 详情 / 新建 / 编辑 / 保存 / 删除 / 审核 / 置顶 / 群组关联 / 配图。
*
* 契约(对齐 usePublishAlbums 与后端 ArticleService):
* 1. 列表 FindBySearchCriteria -> data.Datas(ArticleDisplayData[]),后端已按「群组成员 + 审核」
* 过滤并按置顶、创建时间排序,前端不再筛;
* 2. 详情 FindByKey(articleName) -> ArticleData { Article, FamilyGroups[], Filenames[] },
* 后端在 Article 上直接下发 IsOwner/IsManager/IsMember 与 AuthorLocalName/AuthorAvatarUrl/CoverUrl;
* 3. 编辑一律本地改整包 data,最后 Insert/Update 落库(无独立子表接口);
* 4. 保存判定:Article.CreatedBy 有值走 Update,否则 Insert(新编号由后端号段生成);
* 5. 封面/配图存物理名,展示地址走 absoluteFileUrl(后端已打包 CoverUrl/ImageUrl,绝对地址幂等)。
*/
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { ArticleData } from "@/data/auto/articleData";
import { ArticleDisplayData } from "@/data/auto/articleDisplayData";
import { ArticleFamilyGroupDisplayData } from "@/data/auto/articleFamilyGroupDisplayData";
import { ArticleFilenameDisplayData } from "@/data/auto/articleFilenameDisplayData";
import { ArticleSearchCriteria } from "@/data/auto/articleSearchCriteria";
import { SearchFilter } from "@/data/auto/searchFilter";
import articleService from "@/service/master/articleService";
import {
absoluteFileUrl,
backupFileUrl,
mapBriefImageSrc,
} from "@/utils/fileUrl";
/** 列表一次拉全(移动端分页在文章场景暂不需要) */
const FIND_LIMIT = 999999;
/** 是/否标志(与后端 YesNoPolicy 对齐) */
export const YES = "Y";
export const NO = "N";
/** 创建日期只取年月日:卡片展示用 */
export function formatArticleDate(value: Date | string | undefined): string {
if (!value) {
return "";
}
const time =
typeof value === "string" ? new Date(value).getTime() : value.getTime();
if (!time) {
return "";
}
const d = new Date(time);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** 富文本 HTML 去标签取纯文本,供列表摘要截断 */
export function plainTextOf(html: string | undefined, max = 100): string {
const text = String(html ?? "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
/** 插图限宽样式:rich-text / editor 里的 <img> 默认按原图尺寸渲染,手机屏宽不够就横向撑爆 */
const FIT_IMG_STYLE = "max-width:100%;height:auto;display:block;";
/** 给正文 HTML 里所有 <img> 烘上限宽内联样式(跨端统一口径)。
* scoped 样式穿透不了 rich-text 内容,只能改 HTML 本身;已存 style 的合并并去掉旧尺寸约束。
* 在灌编辑器前调用:样式随 getContents 回写进 Brief 落库,只读态与重开编辑同一效果。 */
export function fitBriefImages(html: string | undefined): string {
return String(html ?? "").replace(/<img\b[^>]*>/gi, (tag) => {
// 去掉旧的宽高属性与 style 里的 max-width/height,再统一烘上限宽样式
const cleaned = tag
.replace(/\s(?:width|height)="[^"]*"/gi, "")
.replace(/\sstyle="([^"]*)"/gi, (_m, s: string) => {
const rest = s
.replace(/max-width:[^;"]*;?/gi, "")
.replace(/height:[^;"]*;?/gi, "")
.trim();
return rest ? ` style="${rest}"` : "";
});
return /\sstyle="/i.test(cleaned)
? cleaned.replace(/\sstyle="/i, ` style="${FIT_IMG_STYLE}`)
: cleaned.replace(/^<img/i, `<img style="${FIT_IMG_STYLE}"`);
});
}
/** 展示用 Brief:限图宽 + 把内联 img src 归一到当前端可达绝对地址(只读 rich-text 与编辑器共用,修 App 上正文插图不显示) */
export function briefForDisplay(html?: string | null): string {
return mapBriefImageSrc(fitBriefImages(String(html ?? "")), "display");
}
/** 存库用 Brief:限图宽 + 把内联 img src 剥成 host 无关相对路径 /FileServer/...(跨环境可移植) */
export function briefForStore(html: string): string {
return mapBriefImageSrc(fitBriefImages(html), "store");
}
export function useArticles() {
const { t } = useLocale();
// ===== 列表态 =====
const list = ref<ArticleDisplayData[]>([]);
const loading = ref(false);
const errorText = ref("");
// ===== 详情 / 编辑态 =====
const detail = ref<ArticleData | null>(null);
const creating = ref(false);
const isEdit = ref(false);
const article = computed<ArticleDisplayData | null>(
() => detail.value?.Article ?? null,
);
const groups = computed<ArticleFamilyGroupDisplayData[]>(
() => detail.value?.FamilyGroups ?? [],
);
const filenames = computed<ArticleFilenameDisplayData[]>(
() => detail.value?.Filenames ?? [],
);
const inDetail = computed(
() => !!article.value?.ArticleName || creating.value,
);
/** 归属:后端 FindByKey 下发 IsOwner/IsManager/IsMember;新建态恒为作者 */
const isOwner = computed(() => creating.value || !!article.value?.IsOwner);
const isGroupManager = computed(
() => !creating.value && !!article.value?.IsManager,
);
const isGroupMember = computed(
() => !creating.value && !!article.value?.IsMember,
);
/** 可管理(改正文/封面/群组/审核/置顶/删除):作者或群管理员 */
const canManage = computed(
() => inDetail.value && (isOwner.value || isGroupManager.value),
);
/** 可删除:作者或群管理员 */
const canDelete = computed(() => canManage.value);
// ===== 列表 =====
async function loadList(keyword = ""): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const criteria = new ArticleSearchCriteria();
const filters: SearchFilter[] = [];
if (keyword.trim()) {
const kw = new SearchFilter();
kw.Column = ArticleDisplayData.ARTICLE_TITLE;
kw.Type = "text";
kw.Operator = "contain";
kw.Value = keyword.trim();
filters.push(kw);
}
criteria.Filter = filters;
criteria.limit = FIND_LIMIT;
criteria.page = 1;
criteria.GridColumns = [];
const data = await articleService.FindBySearchCriteria(criteria);
list.value = (data?.Datas ?? []) as ArticleDisplayData[];
} catch (error: any) {
list.value = [];
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
// ===== 进详情 =====
async function openArticle(articleName: string): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const data = await articleService.FindByKey(articleName);
if (!data?.Article?.ArticleName) {
detail.value = null;
errorText.value = t("MediaPage.noAccess");
return;
}
data.FamilyGroups = data.FamilyGroups ?? [];
data.Filenames = data.Filenames ?? [];
detail.value = data;
} catch (error: any) {
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
async function exitDetail(): Promise<void> {
detail.value = null;
creating.value = false;
isEdit.value = false;
await loadList();
}
// ===== 新建 / 编辑 =====
function startCreate(): void {
const blank = new ArticleData();
blank.FamilyGroups = [];
blank.Filenames = [];
blank.Article.ApprovedIndc = NO;
blank.Article.TopIndc = NO;
detail.value = blank;
creating.value = true;
isEdit.value = true;
}
function startEdit(): void {
isEdit.value = true;
}
async function cancelEdit(): Promise<void> {
if (creating.value) {
await exitDetail();
return;
}
// 整包已空(如弹层关闭事件二次触发、exitDetail 后 detail 已 null)直接返回:
// 否则 article.value!.ArticleName 会踩空抛 "Cannot read properties of null (reading 'ArticleName')"
const name = article.value?.ArticleName;
if (!name) {
return;
}
isEdit.value = false;
await openArticle(name);
}
// ===== 群组关联本地增删(保存整包落库)=====
/** 群组行规整:后端 ArticleFamilyGroupEntity 的 CreatedBy/ArticleName/UpdatedBy 为非空 string,
* 缺键会触发模型校验 400,补空串让新增行既过校验又能被后端按 FamilyName 差量落库 */
function normalizeGroups(
rows: ArticleFamilyGroupDisplayData[],
): ArticleFamilyGroupDisplayData[] {
return rows.map((row) => ({
...row,
CreatedBy: row.CreatedBy ?? "",
ArticleName: row.ArticleName ?? "",
UpdatedBy: row.UpdatedBy ?? "",
}));
}
function addGroup(familyName: string, familyLocalName = ""): void {
if (!detail.value) {
return;
}
const rows = detail.value.FamilyGroups;
if (rows.some((g) => g.FamilyName === familyName)) {
return;
}
const row = new ArticleFamilyGroupDisplayData();
row.FamilyName = familyName;
row.FamilyLocalName = familyLocalName;
rows.push(row);
}
function removeGroup(familyName: string): void {
if (!detail.value) {
return;
}
detail.value.FamilyGroups = detail.value.FamilyGroups.filter(
(g) => g.FamilyName !== familyName,
);
}
// ===== 配图本地增删 =====
function addImage(physicalName: string): void {
if (!detail.value || !physicalName) {
return;
}
const rows = detail.value.Filenames;
if (rows.some((f) => f.FileName === physicalName)) {
return;
}
const row = new ArticleFilenameDisplayData();
row.FileName = physicalName;
rows.push(row);
}
function removeImage(physicalName: string): void {
if (!detail.value) {
return;
}
detail.value.Filenames = detail.value.Filenames.filter(
(f) => f.FileName !== physicalName,
);
}
// ===== 保存 / 删除 / 即时开关 =====
async function save(): Promise<void> {
const submitted = detail.value!;
const entity = submitted.Article;
entity.ArticleTitle = (entity.ArticleTitle || "").trim();
submitted.FamilyGroups = normalizeGroups(submitted.FamilyGroups ?? []);
submitted.Filenames = submitted.Filenames ?? [];
const result = entity.CreatedBy
? await articleService.Update(submitted)
: await articleService.Insert(submitted);
result.FamilyGroups = result.FamilyGroups ?? [];
result.Filenames = result.Filenames ?? [];
detail.value = result;
creating.value = false;
isEdit.value = false;
}
async function remove(): Promise<void> {
const name = article.value?.ArticleName;
if (!name) {
return;
}
await articleService.Delete(name);
await exitDetail();
}
/** 行内即时切换审核/置顶:改本地整包后立即 save 落库(对齐相册即时保存模式) */
async function setFlag(
field: "ApprovedIndc" | "TopIndc",
on: boolean,
): Promise<void> {
if (!detail.value?.Article) {
return;
}
detail.value.Article[field] = on ? YES : NO;
await save();
}
/** 封面展示地址:已保存态用后端打包的 CoverUrl(绝对地址幂等);
* 未保存的新上传只有裸物理名,按落盘口径 /FileServer/Backup/ArticleCover/{ext}/{name} 拼直链 */
const coverUrl = computed(() =>
article.value?.CoverUrl
? absoluteFileUrl(article.value.CoverUrl)
: backupFileUrl("ArticleCover", article.value?.ArticleCoverFilename),
);
return {
// state
list,
loading,
errorText,
detail,
article,
groups,
filenames,
coverUrl,
inDetail,
creating,
isEdit,
isOwner,
isGroupManager,
isGroupMember,
canManage,
canDelete,
// list / detail
loadList,
openArticle,
exitDetail,
// create / edit
startCreate,
startEdit,
cancelEdit,
// groups / images
addGroup,
removeGroup,
addImage,
removeImage,
// save / delete / flags
save,
remove,
setFlag,
// helpers
formatDate: formatArticleDate,
plainText: plainTextOf,
};
}
@@ -0,0 +1,11 @@
import { ArticleDisplayData } from "./articleDisplayData";
import { ArticleFamilyGroupDisplayData } from "./articleFamilyGroupDisplayData";
import { ArticleFilenameDisplayData } from "./articleFilenameDisplayData";
export class ArticleData
{
public Article: ArticleDisplayData = new ArticleDisplayData();
public FamilyGroups: ArticleFamilyGroupDisplayData[] = [];
public Filenames: ArticleFilenameDisplayData[] = [];
}
@@ -0,0 +1,9 @@
import { ArticleEntity } from "./articleEntity";
export class ArticleDisplayData extends ArticleEntity {
public AuthorLocalName: string = "";
public AuthorAvatarUrl: string = "";
public CoverUrl: string = "";
public IsOwner: boolean = false;
public IsMember: boolean = false;
public IsManager: boolean = false;
}
@@ -0,0 +1,40 @@
export class ArticleEntity
{
public CreatedDatetime: Date = new Date();
public CreatedBy: string = "";
public UpdatedDatetime: Date = new Date();
public UpdatedBy: string = "";
public ArticleName: string = "";
public ArticleTitle: string = "";
public Brief: string = "";
public ArticleCoverFilename: string = "";
public ApprovedIndc: string = "";
public TopIndc: string = "";
public OrderBy: number = 0;
static readonly CREATED_DATETIME : string ='CreatedDatetime';
static readonly CREATED_BY : string ='CreatedBy';
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
static readonly UPDATED_BY : string ='UpdatedBy';
static readonly ARTICLE_NAME : string ='ArticleName';
static readonly ARTICLE_TITLE : string ='ArticleTitle';
static readonly BRIEF : string ='Brief';
static readonly ARTICLE_COVER_FILENAME : string ='ArticleCoverFilename';
static readonly APPROVED_INDC : string ='ApprovedIndc';
static readonly TOP_INDC : string ='TopIndc';
static readonly ORDER_BY : string ='OrderBy';
static readonly DB_NAME_ARTICLE : string ='article';
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,article_name,article_title,brief,article_cover_filename,approved_indc,top_indc,order_by';
static readonly DB_CREATED_DATETIME : string ='created_datetime';
static readonly DB_CREATED_BY : string ='created_by';
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
static readonly DB_UPDATED_BY : string ='updated_by';
static readonly DB_ARTICLE_NAME : string ='article_name';
static readonly DB_ARTICLE_TITLE : string ='article_title';
static readonly DB_BRIEF : string ='brief';
static readonly DB_ARTICLE_COVER_FILENAME : string ='article_cover_filename';
static readonly DB_APPROVED_INDC : string ='approved_indc';
static readonly DB_TOP_INDC : string ='top_indc';
static readonly DB_ORDER_BY : string ='order_by';
}
@@ -0,0 +1,8 @@
import { ArticleFamilyGroupEntity } from "./articleFamilyGroupEntity";
export class ArticleFamilyGroupDisplayData extends ArticleFamilyGroupEntity
{
public FamilyLocalName: string = "";
public Notice: string = "";
}
@@ -0,0 +1,25 @@
export class ArticleFamilyGroupEntity
{
public CreatedDatetime: Date = new Date();
public CreatedBy: string = "";
public UpdatedDatetime: Date = new Date();
public UpdatedBy: string = "";
public ArticleName: string = "";
public FamilyName: string = "";
static readonly CREATED_DATETIME : string ='CreatedDatetime';
static readonly CREATED_BY : string ='CreatedBy';
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
static readonly UPDATED_BY : string ='UpdatedBy';
static readonly ARTICLE_NAME : string ='ArticleName';
static readonly FAMILY_NAME : string ='FamilyName';
static readonly DB_NAME_ARTICLE_FAMILY_GROUP : string ='article_family_group';
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,article_name,family_name';
static readonly DB_CREATED_DATETIME : string ='created_datetime';
static readonly DB_CREATED_BY : string ='created_by';
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
static readonly DB_UPDATED_BY : string ='updated_by';
static readonly DB_ARTICLE_NAME : string ='article_name';
static readonly DB_FAMILY_NAME : string ='family_name';
}
@@ -0,0 +1,7 @@
export class ArticleFamilyGroupEntityKey
{
public ArticleName: string = "";
public FamilyName: string = "";
}
@@ -0,0 +1,4 @@
import { ArticleFilenameEntity } from "./articleFilenameEntity";
export class ArticleFilenameDisplayData extends ArticleFilenameEntity {
public ImageUrl: string = "";
}
@@ -0,0 +1,28 @@
export class ArticleFilenameEntity
{
public CreatedDatetime: Date = new Date();
public CreatedBy: string = "";
public UpdatedDatetime: Date = new Date();
public UpdatedBy: string = "";
public FileId: number = 0;
public ArticleName: string = "";
public FileName: string = "";
static readonly CREATED_DATETIME : string ='CreatedDatetime';
static readonly CREATED_BY : string ='CreatedBy';
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
static readonly UPDATED_BY : string ='UpdatedBy';
static readonly FILE_ID : string ='FileId';
static readonly ARTICLE_NAME : string ='ArticleName';
static readonly FILE_NAME : string ='FileName';
static readonly DB_NAME_ARTICLE_FILENAME : string ='article_filename';
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,file_id,article_name,file_name';
static readonly DB_CREATED_DATETIME : string ='created_datetime';
static readonly DB_CREATED_BY : string ='created_by';
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
static readonly DB_UPDATED_BY : string ='updated_by';
static readonly DB_FILE_ID : string ='file_id';
static readonly DB_ARTICLE_NAME : string ='article_name';
static readonly DB_FILE_NAME : string ='file_name';
}
@@ -0,0 +1,8 @@
import { ArticleDisplayData } from "./articleDisplayData";
import { SearchCriteriaResultData } from "./searchCriteriaResultData";
export class ArticleResultData extends SearchCriteriaResultData
{
public Datas: ArticleDisplayData[] = [];
}
@@ -0,0 +1,6 @@
import { AbstractSearchCriteria } from "./abstractSearchCriteria";
export class ArticleSearchCriteria extends AbstractSearchCriteria
{
}
@@ -12,6 +12,46 @@
"avatarUploadedTip": "Avatar updated",
"avatarUploadFailed": "Avatar upload failed"
},
"article": {
"title": "Family Culture",
"searchPlaceholder": "Search article title",
"empty": "No culture articles yet",
"unnamed": "Untitled article",
"noBrief": "No content",
"unknownAuthor": "Youjiala user",
"tagTop": "Pinned",
"tagPending": "Pending",
"editTitle": "Edit Article",
"fieldTitle": "Title",
"titleRequired": "Please enter an article title",
"fieldCover": "Cover",
"coverPick": "Tap to upload cover",
"fieldContent": "Content",
"fmtHeader": "Heading",
"fmtList": "List",
"fmtImage": "Image",
"fieldImages": "Images",
"addImage": "Add image",
"noImage": "No images",
"imageTapHint": "Tap a thumbnail to insert it into the content again",
"imageInserted": "Inserted into content",
"editorNotReady": "Editor not ready yet, please retry",
"fieldGroups": "Groups",
"addGroup": "Add group",
"noGroup": "No linked group",
"noGroupCandidate": "No group available to link",
"uploadFailed": "Upload failed",
"tabOverview": "Overview",
"tabImages": "Images",
"tabGroups": "Groups",
"fieldApproved": "Approved",
"fieldTop": "Pin to top",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"deleteTitle": "Delete Article",
"deleteConfirm": "Delete this article and its linked records?"
},
"LoginForm": {
"userLabel": "Username",
"passwordLabel": "Password",
@@ -12,6 +12,46 @@
"avatarUploadedTip": "头像已更新",
"avatarUploadFailed": "头像上传失败"
},
"article": {
"title": "有家文化传承",
"searchPlaceholder": "搜索文章标题",
"empty": "还没有文化传承文章",
"unnamed": "未命名文章",
"noBrief": "暂无内容",
"unknownAuthor": "有家用户",
"tagTop": "置顶",
"tagPending": "待审核",
"editTitle": "编辑文章",
"fieldTitle": "标题",
"titleRequired": "请输入文章标题",
"fieldCover": "封面",
"coverPick": "点击上传封面",
"fieldContent": "正文",
"fmtHeader": "标题",
"fmtList": "列表",
"fmtImage": "插图",
"fieldImages": "配图",
"addImage": "添加配图",
"noImage": "暂无配图",
"imageTapHint": "点击缩略图可重新插入正文",
"imageInserted": "已插入正文",
"editorNotReady": "编辑器未就绪,请稍候再试",
"fieldGroups": "关联群组",
"addGroup": "添加群组",
"noGroup": "未关联任何群组",
"noGroupCandidate": "没有可关联的群组",
"uploadFailed": "上传失败",
"tabOverview": "概览",
"tabImages": "图片",
"tabGroups": "群组",
"fieldApproved": "已审核",
"fieldTop": "置顶",
"delete": "删除",
"cancel": "取消",
"save": "保存",
"deleteTitle": "删除文章",
"deleteConfirm": "确定删除这篇文章及其关联记录吗?"
},
"LoginForm": {
"userLabel": "用户名",
"passwordLabel": "密码",
+260
View File
@@ -0,0 +1,260 @@
# 有家文化传承(Article)功能归档
> 适用范围:「有家文化传承」(文章 / Article)从**数据模型、编号、权限可见性、后端服务、文件落盘与地址、前端编排、富文本编辑器、详情页 UI** 的整条链路。
> 本文是该特性的单一事实来源(single source of truth)。
> 涉及工程:后端 `CloudBuilder`(.NET 8 / Furion,`CloudBuilder.Master` + `CloudBuilder.Master.Service`)、前端 `uniapp youjiala`(Vue3 + uni-app + uview-pro)。
> 相关文档:文件地址归一见 [file-url.md](./file-url.md),权限体系见 [permission.md](./permission.md),页面路由见 [page-routing.md](./page-routing.md)。
---
## 1. 一句话概览
一篇「文化传承」文章 = 主表 `article` + 关联的族谱群组 `article_family_group`(决定谁可见)+ 配图明细 `article_filename`。作者撰写并挂群组,**群组正式成员可读**,**未审核文章仅作者本人可见**;作者/群管理员可改正文、封面、配图、审核与置顶。首页以卡片列表呈现,详情页分「管理态 / 只读态」两副面孔,编辑走富文本弹层。
---
## 2. 数据模型
三张表,主键均为业务编号 `article_name`(号段生成,见第 3 节)。
### 2.1 主表 `article`(`CloudBuilder.Master/Entity/ArticleEntity.auto.cs`)
| 字段(DB) | 属性 | 含义 | 备注 |
|---|---|---|---|
| `article_name` | `ArticleName` | 主键编号 | 号段生成,见 §3 |
| `article_title` | `ArticleTitle` | 标题 | 首页搜索按此模糊 |
| `brief` | `Brief` | 正文富文本 HTML | `<img>` 内联烘上限宽样式,见 §8.2 |
| `article_cover_filename` | `ArticleCoverFilename` | 封面**物理文件名** | 非 URL;展示时打包,见 §6 |
| `approved_indc` | `ApprovedIndc` | 是否已审核 `Y/N` | 新文章默认 `N` |
| `top_indc` | `TopIndc` | 是否置顶 `Y/N` | 新文章默认 `N` |
| `order_by` | `OrderBy` (int) | 排序值 | 升序,值小靠前;见 §5 |
| `created_by` / `created_datetime` | | 作者账号名 / 建单时间 | 作者中文名、头像由此 join 回填 |
| `updated_by` / `updated_datetime` | | 最后更新人 / 时间 | |
### 2.2 群组关联 `article_family_group`(`ArticleFamilyGroupEntity`)
一行一条「文章 ↔ 族谱群组」关系:`ArticleName` + `FamilyName`。决定文章对哪些群组成员可见。增删走差量(`SaveArticleFamilyGroup`)。
### 2.3 配图明细 `article_filename`(`ArticleFilenameEntity.auto.cs`)
一行一张配图:`ArticleName` + `FileName`(物理名,可空)+ `FileId`(明细序号,按此升序返回)。`CreatedBy/UpdatedBy` 非空。增删走差量(`SaveArticleFilenames`),**仅作者/群管理员落子表**。
---
## 3. 编号生成
`article_name` 走号段生成器 `ISecurityGeneratorNumberService.GetNo`(`CloudBuilder.Master/Policy/MasterPrefixPolicy.cs`):
| 常量 | 值 |
|---|---|
| `ARTICLE` / `ARTICLE_ARTICLE_NAME` | `ARTICLE` / `ARTICLE_NAME`(号段键) |
| `ARTICLE_PREFIX` | `PP` |
| `ARTICLE_FORMAT` | `yymmdd00000` |
即与相册同格式但**独立计数**,样例 `PP26102200001`。
---
## 4. 权限与可见性(核心闸门)
权限策略名 `MasterPermissionPolicy.Article = "Article"`,四操作 `Find/Insert/Update/Delete`,标注在 `ArticleService.auto.cs` 各方法上(`[PermissionOperate]`)。
### 4.1 三档身份(`FindByKey` / `Update` 内计算)
| 身份 | 判定 | 能力 |
|---|---|---|
| **作者 isOwner** | `article.created_by == 当前 userId` | 全权:读/改正文/封面/配图/群组/审核/置顶/删 |
| **群管理员 isManager** | 关联群组的 `family_group_manager` 行 `ValidIndc=Y` 且 `ManagerIndc=Y` | 改信息/群组/审核/置顶、删;**非作者** |
| **群成员 isMember** | 关联群组的 `family_group_manager` 行 `ValidIndc=Y` | 只读(且文章须已审核) |
`isOwner` 时 `isMember`/`isManager` 直接置真(作者天然全权)。
### 4.2 单篇读取闸门(`FindByKey`)
```
非作者且非成员 → throw ValidatedException("没有权限.")
非作者且未审核(ApprovedIndc≠Y) → throw ValidatedException("文章尚未审核通过.")
```
### 4.3 列表可见性(`FindBySearchCriteria`)
先取「我加入的正式群组」`myGroups`(`family_group_manager.ValidIndc=Y`),再取「这些群组下挂的文章编号」`articleNamesInMyGroups`,过滤:
```
(CreatedBy == 我 OR articleNamesInMyGroups.Contains(ArticleName))
AND (ApprovedIndc == Y OR CreatedBy == 我)
```
**结论:未审核文章只有作者本人能在列表看到**——这是设计闸门,不是 bug。要让普通成员(如 003)看到某篇文章,作者/群管理员须把该文章的「已审核」开关打开。
### 4.4 改/删的字段级防护(`Update`)
- 非作者:`CreatedBy`/`CreatedDatetime` 一律以库为准(防客户端伪造归属)。
- 普通成员(非 owner 且非 manager):`ArticleTitle/Brief/ArticleCoverFilename/ApprovedIndc/TopIndc/OrderBy` 全部回灌库中值——**改不动**。
- 群组关联与配图子表:仅 `isOwner || isManager` 才调用 `SaveArticleFamilyGroup` / `SaveArticleFilenames`。
- `Delete`:作者或任一关联群管理员,级联删群组与配图子表行。
---
## 5. 排序规则
列表排序在服务端 LINQ 硬编码(`FindBySearchCriteria`):
```csharp
.OrderByDescending(x => x.TopIndc == YesNoPolicy.YES) // 置顶优先
.ThenBy(x => x.OrderBy) // 排序值升序(CloudBuilder 约定值小靠前)
.ThenByDescending(x => x.CreatedDatetime); // 创建时间倒序兜底
```
即「**置顶 + 排序组合**」。`OrderBy` 升序与明细列表、Locale 列表口径一致;时间倒序仅作同 `order_by`(如都为 0)时的稳定兜底。
> ⚠️ **已知边界**:App 内暂无 `order_by` 编辑入口(管理态只有「置顶」「已审核」两个开关),故当前所有文章 `order_by=0`,排序组合实际退化为「置顶 + 时间倒序」。要启用「排序」这一级,需在 CloudBuilder 管理 GUI / 数据库设 `order_by`,或在管理态面板补一个「排序值」数字输入框(走即时保存,与置顶开关同机制)。
---
## 6. 文件落盘与访问地址
封面与配图**存物理文件名**(裸名如 `uni-1792639791154-1-1.jpg`),不存 URL;展示时按上传目录打包。
### 6.1 落盘目录常量(`CloudBuilder.Master/Policy/UploadFolderPolicy.cs`)
| 常量 | 值 | 对应 |
|---|---|---|
| `ARTICLE_COVER` | `ArticleCover` | `article.article_cover_filename` |
| `ARTICLE_IMAGE` | `ArticleImage` | `article_filename.file_name` |
前端上传时 `FilePath` 头须与之一致。
### 6.2 后端打包(`ArticleService.PackCover / PackImage`)
复用头像同机制 `FileTypePolicy.PackAvatarUrl(service, folder, fileName)`,结构:
```
{ApplicationUrl}/FileServer/Backup/{folder}/{ext}/{fileName}
```
`FindByKey` 里对每条配图 `ImageUrl = PackImage(FileName)`;封面 `CoverUrl = PackCover(...)`。作者头像走 `USER_AVATAR` 目录。
### 6.3 前端直链拼装(`utils/fileUrl.ts` 新增 `backupFileUrl`)
上传即落盘(与业务数据是否保存无关),故**保存前预览也能取到图**。前端用 `backupFileUrl(folder, name)` 按后端同口径拼直链:
```
backupFileUrl(folder, fileName) -> string
裸物理名 -> absoluteFileUrl(`/FileServer/Backup/${folder}/${ext}/${name}`)
已是 /FileServer/ 直链、http/blob/data、绝对路径 -> 幂等放行(过 absoluteFileUrl)
```
**历史坑**:曾把裸名直接 `absoluteFileUrl(physicalName)` 拼成坏地址 `{base}/uni-xxx.jpg`(缺 `/FileServer/Backup/{folder}/{ext}/`),既烘进 `Brief` 又导致预览坏。现封面预览、内联插图、缩略图统一走 `backupFileUrl`。地址归一细则见 [file-url.md](./file-url.md)。
---
## 7. 前端架构
| 层 | 文件 | 职责 |
|---|---|---|
| API(自动生成,**不可手改**) | `service/master/articleService.ts` | `/Article/FindByKey`、`/FindBySearchCriteria`、`/Insert`、`/Update`、`/Delete` |
| 数据实体(自动生成) | `data/auto/articleEntity.ts`、`articleFilenameEntity.ts`、`articleFamilyGroupEntity.ts`、`articleData.ts`、`articleResultData.ts` | 与后端字段对齐 |
| 组合式 | `composables/useArticles.ts` | 状态与编排:`useArticles()` 返回 `{ article, groups, filenames, loading, errorText, coverUrl, setFlag, ... }`;导出 `YES/NO`、`formatArticleDate`、`plainTextOf`、`fitBriefImages` |
| 编辑弹层 | `components/article-editor/article-editor.vue` | `u-popup` + uni `<editor>`:正文富文本、封面上传、配图上传/复用、保存回写 |
| 详情页 | `pages/article/article.vue`(路由 `pages/article/article`) | 管理态 / 只读态两副面孔、审核与置顶开关、配图与群组 Tab |
| 首页卡片 | `components/family-tree-home/family-tree-home.vue` | 「有家文化传承」区列表卡片(封面、置顶/待审 chip、摘要) |
`useArticles.ts` 明确「后端已排好序,前端不再筛」——列表顺序以服务端为准。
---
## 8. 富文本编辑器要点(`article-editor.vue`)
### 8.1 跨端上下文获取
H5 端 `e.detail.editor` 为 `undefined`,须用:
```
uni.createSelectorQuery().in(instance.proxy).select("#id").context(res => res.context).exec()
```
取到 editor 上下文后再 `setContents / insertImage / getContents`。
### 8.2 插图限宽 `fitBriefImages`
`rich-text` / `<editor>` 里的 `<img>` 默认按**原图尺寸**渲染,手机屏宽不够就横向撑爆;且 scoped 样式穿不透 `rich-text` 内容,只能改 HTML 本身。`fitBriefImages(html)` 给每个 `<img>` 剥掉 `width/height` 属性与旧 `max-width/height` 内联样式,再烘上:
```
max-width:100%;height:auto;display:block;
```
调用点:**灌编辑器前**(`currentHtml()`)与**保存前**(`onSave` 里 `readHtml()` 之后)各一次,使样式随 `getContents` 回写进 `Brief` 落库,只读态与重开编辑同一效果。详情页 `article.vue` 用 `briefHtml = computed(() => fitBriefImages(...))` 绑 `rich-text`。
### 8.3 编辑弹层二次打开正文空白的坑(重要)
`uview-pro` 的 `u-popup` 内容层是 `v-if="visibleSync"`——**关闭即销毁**,重开时内部 `<editor>` 是全新实例;而模块级 `editorCtx`/`editorReady` 跨开关持久,仍指向**已销毁的旧实例**,`setContents` 静默失效 → 第二次打开正文空白。
修复三件套:
- 抽 `currentHtml()` 统一取最新 `Brief`(过 `fitBriefImages`)。
- `bindEditorContext`(`@ready` 回调):在 `visible` 且无挂起内容时重取最新 `Brief` 并灌入。
- `watch(visible)` 打开分支:对旧 `ctx.setContents` 包 `try/catch`,失败则留给新实例 `@ready` 补灌。
- `readHtml` 的 `fail` 分支兜底**当前 `Brief`**(绝不返回空串,否则会把正文洗没)。
### 8.4 复用已上传配图(`onInsertExisting`)
配图缩略图可点,把**已上传**的图重新插回正文(不重复上传):
```
onInsertExisting(img):
src = absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName)
编辑器未就绪 -> toast(article.editorNotReady)
否则 editorCtx.insertImage({ src, alt }, () -> toast(article.imageInserted))
```
列表卡片/详情页缩略图 `src` 同口径:`absoluteFileUrl(ImageUrl) || backupFileUrl("ArticleImage", FileName)`。
---
## 9. 详情页 UI 口径
- **两态均不展示封面**:管理态与只读态都移除了封面块;封面只保留在**首页列表卡片**与**编辑弹层上传项**。(产品拍板:详情页不显示封面,其他正常显示。)
- **只读态「今日头条」式通栏直排**:不做卡片包裹——`.ar-body` 去白底/圆角/阴影/内边距,标题→作者/日期→分隔线→正文顺排;`.ar-content` 行高 52rpx、色 `#1f2329`。
- **管理态**:编号条(编辑/删除图标,编辑图标有效名 `edit-pen`)+ 概览/图片/群组 Tab + 审核/置顶开关。
- **右侧边距修复**:`.article-page__scroll`(`scroll-view`)原 `padding: 24rpx` 但盒模型未设 `border-box`,padding 加到 100% 宽**之外**,右侧内边距溢出屏、把日期与正文插图右缘同宽裁掉(左有留白右无、不对称)。修复:
```css
.article-page__scroll {
box-sizing: border-box; /* padding 计入宽度内 */
overflow-x: hidden; /* 横向溢出兜底 */
flex: 1; min-height: 0; padding: 24rpx;
}
```
管理态与只读态共用此滚动容器,一改两治。浏览器实测(376px 视口):日期完整、插图左右各约 12px 对称、`scrollWidth == clientWidth` 无横向溢出。
---
## 10. 国际化(`locale/lang/zh-CN.json` / `en-US.json`,`article` 段)
本特性新增关键提示键(节选):
| 键 | 中文 |
|---|---|
| `article.imageTapHint` | 点击缩略图可重新插入正文 |
| `article.imageInserted` | 已插入正文 |
| `article.editorNotReady` | 编辑器未就绪,请稍候再试 |
其余如 `article.unnamed`、`article.fieldTop`、`article.tagTop`、`article.tagPending` 等沿用既有命名。
---
## 11. 已知边界与待办
1. **`order_by` 无 App 内编辑入口**:见 §5,排序组合的「排序」级暂不生效。
2. **详情页直达路由**:直接访问 `http://localhost:5173/#/pages/article/article?name=...` 会渲染成首页 dashboard,须从首页文章卡点进去才走短地址 `/article?name=...`。独立于本特性,待单独跟进。
3. **后端排序/可见性改动需重启**:`ArticleService.auto.cs` 是服务代码逻辑,非权限数据,改后须重新生成并重启 `CloudBuilder.Web.Entry`(重登无效)。
---
## 12. 验证方法(自动化优先)
- **前端类型裁决**:`node D:\Docker\uniapp\youjiala\node_modules\vue-tsc\bin\vue-tsc.js --noEmit`,看退出码 0(eslint 风格噪声一贯忽略)。
- **后端编译**:`dotnet build CloudBuilder.Master.Service\...csproj`,确认 0 error(既有 `DmsFileService` nullable 警告无关)。
- **连库核对**:`node` 绝对路径 + `mysql2`(`nas.youjiala.com:3307/Furion`,utf8mb4),核对 `article` / `article_family_group` / `article_filename` / `family_group_manager` 数据链与排序。PowerShell 内嵌引号会毁 `node -e`,须写临时 `.js`(放已 gitignore 的 `.dbtmp/`)执行。
- **视觉核验**:浏览器子代理设手机竖屏(约 376px)截图,量日期/插图右缘留白与 `scrollWidth vs clientWidth`。
+4
View File
@@ -155,6 +155,10 @@
// 共享大厅(短地址 /share-hall):文件页快捷入口跳入,家人共享目录浏览与组内建目录
{
"path": "pages/share-hall/share-hall"
},
// 有家文化传承文章详情(短地址 /article):首页文章卡跳入,只读/管理两态 + 底部富文本编辑
{
"path": "pages/article/article"
}
],
// 全局配置
@@ -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>
+2
View File
@@ -78,6 +78,8 @@ export const PAGE_PATHS = {
shareHall: "pages/share-hall/share-hall",
// 我的主题(短地址 /theme):「我的」页跳入,主题配色与深色模式设置
themePage: "pages/theme/theme",
// 有家文化传承文章详情(短地址 /article):首页文章卡「+」/点条目跳入,只读/管理两态
articlePage: "pages/article/article",
} as const;
export type PageName = keyof typeof PAGE_PATHS;
@@ -0,0 +1,62 @@
import { request } from "@/utils/request";
import type { ArticleData } from "@/data/auto/articleData";
import type { ArticleSearchCriteria } from "@/data/auto/articleSearchCriteria";
import type { ArticleResultData } from "@/data/auto/articleResultData";
class ArticleService{
async FindByKey(data: string) {
return request<ArticleData>(
{
url: "/Article/FindByKey?key="+data,
method: "post",
data,
}
)
};
async FindBySearchCriteria(data: ArticleSearchCriteria) {
return request<ArticleResultData>(
{
url: "/Article/FindBySearchCriteria",
method: "post",
data,
}
)
};
async Insert(data: ArticleData) {
return request<ArticleData>(
{
url: "/Article/Insert",
method: "post",
data,
}
)
};
async Update(data: ArticleData) {
return request<ArticleData>(
{
url: "/Article/Update",
method: "post",
data,
}
)
};
async Delete(data: string) {
return request<any>(
{
url: "/Article/Delete?key="+data,
method: "post",
data,
}
)
};
}
const articleService= new ArticleService();
export default articleService;
+61
View File
@@ -146,6 +146,67 @@ export function wanFileUrl(path?: string | null): string {
* 提交用的相对路径 /FileServer/... 与裸物理名(无主机)、blob:/data:/本地路径均不含“主机+/FileServer”,天然被排除 */
const FILE_URL_PATH_MARKER = "/FileServer/";
/**
* 上传即落盘的备份文件直链:/FileServer/Backup/{folder}/{ext}/{物理名}。
*
* 为什么需要:DmsFile/UploadFile 上传成功后文件立刻落盘(与是否保存业务数据无关),
* 后端读取态才用 PackAvatarUrl 把物理名打成这个结构;前端「保存前预览 / 富文本内联插图」
* 拿不到后端打包地址,直接 absoluteFileUrl(裸物理名) 会拼出 {base}/uni-xxx.jpg 的坏地址。
* 这里按后端同口径自行拼相对路径再过 absoluteFileUrl(H5 锁 origin、App 跟线路),
* 保存前后同一个地址都能取到图。folder 须与后端 UploadFolderPolicy 一致(如 ArticleCover / ArticleImage)。
*/
export function backupFileUrl(
folder: string,
fileName?: string | null,
): string {
const name = String(fileName ?? "").trim();
if (!name) {
return "";
}
// 已是绝对/相对直链或本地资源:交给 absoluteFileUrl 归一,不再重复打包
if (
name.includes(FILE_URL_PATH_MARKER) ||
/^(?:https?:|blob:|data:|\/)/i.test(name)
) {
return absoluteFileUrl(name);
}
const dot = name.lastIndexOf(".");
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "ext";
return absoluteFileUrl(`/FileServer/Backup/${folder}/${ext}/${name}`);
}
/**
* 改写富文本(文章正文 Brief)HTML 里每个 <img> 的 src。
*
* 为什么单独处理:响应层 normalizePayloadFileUrls 只改「整个字段值就是绝对 FileServer URL」的串,
* 而 Brief 是 HTML(以 <p> 开头),absoluteHost 判非绝对 → 漏改。后端 Debug 会把 localhost:5235
* 打进正文内联 <img src>,App 上 localhost 指向手机自己,插图必挂;封面/配图走 absoluteFileUrl 才没事。
* - display:把 src 归一到当前端可达的绝对地址(absoluteFileUrl,App 跟线路、H5 锁 origin);
* - store:把 src 剥成 host 无关的相对路径 /FileServer/...(toPathAndQuery),存库后跨环境可移植。
* 判据极窄:只碰含 /FileServer/ 的地址;blob:/data:/file:/设备路径等本地资源、外部图 URL 一律原样放行。
*/
export function mapBriefImageSrc(
html: string,
mode: "display" | "store",
): string {
return String(html ?? "").replace(
/(<img\b[^>]*?\ssrc=)(["'])([^"']*)\2/gi,
(whole, prefix: string, quote: string, src: string) => {
const trimmed = String(src).trim();
if (
!trimmed ||
isLocalResource(trimmed) ||
!trimmed.includes(FILE_URL_PATH_MARKER)
) {
return whole;
}
const next =
mode === "display" ? absoluteFileUrl(trimmed) : toPathAndQuery(trimmed);
return next ? `${prefix}${quote}${next}${quote}` : whole;
},
);
}
/** 取绝对 http(s) 地址的主机(小写、含端口);非绝对地址返回 null */
function absoluteHost(url: string): string | null {
const matched = /^https?:\/\/([^/]+)/i.exec(url);