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

+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`。