FForm Platform
enzh-CN

公开内容与 CMS

从可编辑 CMS 记录发布快速、可收录的双语公开页面,而不是硬编码网站页面。

FormPlatform CMS

CMS 是独立的 `FormPlatform.Cms` 扩展包,不在 `Program.cs`、`Data` 或 `Hosting` 中保留 CMS 专属注册。宿主只负责加载 `Modules/FormPlatform.Cms/module.json`;模块自行注册 CMS 的服务、迁移、系统表单和端点。管理端继续是表单驱动的 Vue 应用;公开页面则由 ASP.NET Core 直接输出 HTML,因此不需要等待 SPA JavaScript 执行,适合搜索引擎、社交媒体爬虫和低性能设备。

开发环境可在 `outputs/FormPlatform.Cms` 中运行 `pwsh .\build-module.ps1`,将模块安装到 `FormPlatform/Modules/FormPlatform.Cms`。发布时,standalone 与 DNN 的建包脚本会自动构建并携带该模块。若未安装模块,FormPlatform 的其他功能不受影响,CMS 路由和 CMS 系统表单也不会出现。

启用后的入口

安装模块并启动应用后,会自动执行 `FormPlatform.Cms/001_cms_schema` 及后续不可变迁移,并创建系统表单:

/forms/f0000000-0000-0000-0000-000000000070
/forms/f0000000-0000-0000-0000-000000000072

左侧 **Content** 和 **CMS Content Studio** 菜单仅对 `Administrator` 可见。内容中心 DataGrid 可创建、编辑、删除、搜索和导出内容;Content Studio 是完整编辑入口,管理结构化区块、富文本、Markdown、分类、标签和公开导航树。`status=published` 且处于有效发布时间范围的记录才会公开。

旧页面的 `bodyMarkdown` 仍可在 DataGrid 中维护。保存到 Content Studio 后,会作为 Markdown 区块兼容呈现。

公开 URL

建议使用带语言前缀的稳定 URL:

/zh-CN/docs/getting-started
/en/docs/getting-started

如果 URL 不带语言前缀,CMS 使用 `Cms:DefaultCulture`。Slug 可包含多级段,例如 `docs/install/windows`;每一段只能使用小写字母、数字和 `-`。`api`、`forms`、`admin`、`assets`、`media`、`respondent` 等系统路径不能作为首段。

公开页面自动包含:canonical URL、description、Open Graph 元数据、全局 Tailwind 样式、`/sitemap.xml` 和 `/robots.txt`。

配置

非敏感的 CMS 配置可放在 `appsettings.json`、部署环境变量或独立配置文件:

{
  "Cms": {
    "DefaultCulture": "zh-CN",
    "PublicCultures": ["zh-CN", "en"],
    "MediaRoot": "D:\\FormPlatformMedia",
    "OrganizationName": "FormPlatform",
    "PrivacyContactEmail": "privacy@formplatform.net",
    "MaxUploadBytes": 52428800,
    "AllowedExtensions": [".png", ".jpg", ".jpeg", ".webp", ".pdf", ".docx", ".xlsx", ".zip"]
  }
}

CMS 首次启动会创建英文公开页面 `/en/privacy-policy`,作为 Facebook Privacy Policy URL 的可编辑基础文本。必须将 `OrganizationName` 和 `PrivacyContactEmail` 改为实际组织和实际可接收删除请求的邮箱;此记录只创建一次,以后通过 Content Center 修改不会被覆盖。

如果系统中尚不存在 `/en/platform`,CMS 还会一次性建立可编辑的中英文产品站和开发文档站内容,包括平台功能、表单、数据模型与 ORM、问卷、商城、CMS、安全部署、架构、开发指南、模块、扩展、测试、身份访问、国际化和移动端/DNN 集成页面,以及 `main-en`、`main-zh-CN`、`docs-en`、`docs-zh-CN` 导航树。它们都是普通 `cms_content` 和导航记录;创建后由管理员在 Content Studio 中维护,后续启动不会覆盖文字、重新插入删除的记录或替换导航。

CMS 的新上传由共享 `Media` 服务保存;默认 `Media:StorageRoot` 是应用私有目录,也可切换至 AWS S3 或其他 S3 兼容对象存储。历史 CMS 资产仍兼容 `Cms:MediaRoot` 的本地读取。CMS 通过 `/media/{assetId}/{name}` 受控输出文件,而不会暴露物理文件夹。

媒体上传控件与 API

`cmsFileUpload` 是 `FormPlatform.Cms` 浏览器扩展中的数据控件,不会进入宿主的内置控件清单。模块安装时会将 `/extension-assets/FormPlatform.Cms/index.js` 追加到客户端扩展清单,因此 Designer 和 Viewer 都会自动注册它;卸载 CMS 后该控件及 CMS 媒体 API 一并不可用。

它的值是媒体 ID,可直接绑定给任意表单字段。属性包括:`Label`、`Accept`(例如 `.png,.pdf`)和 `isPublic`。`CMS_Media_Upload` 系统表单是它的默认使用示例。

第一版媒体上传 API 为:

POST /api/admin/cms/media
Content-Type: multipart/form-data

字段:`file`(必填)、`altText`(可选)、`isPublic`(默认 `true`)。接口要求已认证用户拥有 CMS 媒体的 Upload 权限;`Administrator` 始终拥有该权限。成功后返回媒体 ID 和可引用 URL。CMS 文件夹/标签元数据保存在 `cms_media_asset`,文件字节保存于共享 `IMediaService` 所选的 `Media` Provider,不放入页面记录或问卷文件表。详细 S3 配置与角色矩阵见 CMS 模块的 `MEDIA_STORAGE_AND_PERMISSIONS.zh-CN.md`。

Tailwind CSS

`layoutClass` 是页面最外层 `<main>` 的 Tailwind 类,例如:

mx-auto max-w-5xl px-6 py-16

保存内容时,CMS 会将页面 Layout、各区块 `className` / `cssClass`、导航项 class 和高级 HTML 中经清理保留的 class 写入现有全局 CSS 候选索引。后台工作器随后重新生成 `/api/assets/forms.css`;公开页面引用该样式表。模块自己的 Vue 和公开 HTML 固定 class 也会自动注册,因此不依赖主 Vite 扫描模块目录。

产品网站与开发文档站

CMS 的公开站点不是 SPA 页面:它直接由服务器输出 HTML,因此同时适合作为产品介绍、推广落地页和可被搜索引擎索引的技术文档。

语言导航和页面目录

  • 主导航按 `main-<culture>` 读取,例如英文 `main-en`、中文 `main-zh-CN`。若语言树不存在,兼容性回退到原有 `main`。
  • 页面 **Page navigation tree** 属性可指定二级文档树,例如 `docs-en` 或 `docs-zh-CN`。指定后,公开页会使用左侧目录与右侧正文的文档布局;不指定则是普通页面布局。
  • 中英文翻译页面使用相同的 **Translation key**。公开页会查找同一翻译关联下、当前已经发布的其它语言页面,并输出语言切换链接。标题、slug、SEO、导航树和正文仍可各自不同。

为了避免错误语言链接,`main-en` 和 `docs-en` 中的 content 类型导航项应指向英文内容记录;中文树则指向中文内容记录。

面向产品与文档的区块

区块用途
Hero推广页首屏:eyebrow、主标题、说明和两个 CTA 链接。作为第一页区块时会自动承担 H1。
Feature cards一至四列能力卡片,可设置标题、说明、链接与链接文字。
Code example安全编码的代码块,适合安装命令、配置和 API 示例。

这些是 CMS 的正式区块,不要求编辑者书写 HTML。高级编辑者仍可使用 Custom HTML 来完成特殊视觉设计,但不应把脚本或交互业务逻辑放入其中。

安全边界

  • Markdown 会先 HTML 编码;渲染标题、段落、列表、代码块和安全的 `http(s)`/站内链接。
  • 高级 Custom HTML 只保留标签、属性和 URL 白名单;脚本、事件属性、嵌入对象和不安全 URL 会被服务器移除。
  • 文件名会清理为文件名部分,磁盘名使用随机 UUID;编辑者无法指定物理路径。
  • 公开媒体仅输出 `is_public=true` 的资源。
  • 删除内容时仅删除页面、修订和 CSS 候选;媒体需单独管理,避免误删被其他页面引用的文件。

后续路线

1. 翻译关联编辑、301 slug 重定向和版本恢复。

2. PostgreSQL / SQL Server 原生全文索引与图片缩略图。

3. 媒体回收站的保留期、批量操作和永久清理策略。

---

共享媒体服务

目标

`IMediaService` 是 FormPlatform 的统一二进制媒体基础设施。它把文件字节、通用元数据和“哪条业务记录正在引用该文件”分开管理,让 CMS、未来的表单上传、商城商品图片、文档模块和第三方模块使用同一套资产 ID、存储策略和删除保护。

它不是把所有业务媒体 UI 强行做成相同界面:例如 CMS 仍然拥有文件夹、标签、图片替代文本编辑器和公开内容校验;商城仍可拥有商品图库和商品权限。它们共同使用的是可靠的资产底座。

数据与存储

启动时 Core migration `FormPlatform.Core/008_shared_media_library` 建立:

  • `app_media_asset`:资产 ID、所属模块、文件名、MIME 类型、大小、SHA-256、公开状态、可访问性文本、逻辑删除状态。
  • `app_media_reference`:模块业务对象到资产的类型化引用。

实际文件默认不在数据库、不在 `wwwroot`,而是保存到 `Media:StorageRoot`(默认 `App_Data/Media`)。数据库列表、过滤和权限判断不需要读取文件内容;下载才会打开字节流。生产环境内置支持 `local` 与 S3 API;`s3` Provider 可连接 AWS S3、MinIO、Cloudflare R2、Wasabi 等 S3 兼容对象存储。Azure Blob、NAS 或公司内部服务可由服务器模块替换 `IMediaStorage`,而不改变调用 `IMediaService` 的模块。CMS 的 S3 配置、角色媒体权限和迁移注意事项见模块包中的 `MEDIA_STORAGE_AND_PERMISSIONS.zh-CN.md`。

"Media": {
  "StorageRoot": "App_Data/Media",
  "MaxUploadBytes": 52428800,
  "AllowedExtensions": []
}

`AllowedExtensions` 为空表示基础服务不增加全局限制;调用模块可以设置自己的更严格规则。CMS 继续使用 `Cms:AllowedExtensions`。

模块调用范例

模块应使用稳定、短小的 owner 命名空间,例如 `commerce`、`my-company.booking`。同一个模块只能把自己的资产引用到自己的记录。

public sealed class ProductImageService(IMediaService media)
{
    public async Task<MediaAsset> UploadAsync(IFormFile file, string productId, string userId, CancellationToken ct)
    {
        await using var stream = file.OpenReadStream();
        var image = await media.CreateAsync(stream, new MediaCreateRequest(
            Owner: "commerce",
            OriginalName: file.FileName,
            ContentType: file.ContentType,
            FileSize: file.Length,
            IsPublic: true,
            CreatedBy: userId,
            Title: file.FileName,
            AltText: "Product image"), ct);

        await media.ReplaceReferencesAsync(
            owner: "commerce",
            targetType: "product",
            targetId: productId,
            references: [new MediaReferenceInput(image.Id, "primary-image")],
            cancellationToken: ct);
        return image;
    }
}

使用 `ReplaceReferencesAsync` 而不是仅在业务表保存 ID,可使服务在删除前发现引用并拒绝删除。若业务对象支持多张图片,把每张图片放在同一个引用数组中,并以 `referenceKind` 表达用途。

HTTP 路由

  • `GET /media-assets/{id}/{optionalName}`:匿名访问,仅返回公开、未删除的共享资产,支持 Range 下载。
  • `/api/admin/media/*`:管理员诊断/上传/元数据修改/删除 API。

CMS 保留原有的 `/media/{id}/{name}`,以保障已发布的 CMS URL 不变。新模块应使用 `/media-assets/...` 或直接调用 SDK 服务。

当前迁移状态

CMS 的**新上传**已经由 `IMediaService` 创建,仍同步一份 CMS 专属投影以保留文件夹、标签和旧 URL。问卷的新上传也已经接入:`survey_files` 保留 deployment、response、respondent 和文件名等授权元数据,但新的文件字节改由共享媒体服务保存,表中以 `shared_media_id` 关联。

问卷仍只能通过既有的受访者或管理员下载 API 访问文件,**不会**暴露到匿名的 `/media-assets/...`;答卷提交后会建立 `owner = survey` 的共享引用,避免被通用删除 API 误删。旧 CMS 和旧问卷 BLOB 文件继续兼容读取,升级不会批量移动用户文件。

内置 FileUpload 控件

内置 `Input` 控件的 `Type = File` 现在既可用于问卷,也可用于普通系统表单、功能表单和商城表单。设计器的 **General / 常规** 页会显示以下属性:

属性用途
`Upload mode / 上传模式`决定文件由哪个服务接收。
`Upload API URL`仅在 `Third-party custom API` 模式下出现;支持 `{propertyName}` 代换表达式。
`Media visibility / 媒体可见性`平台共享媒体模式可选 `Private` 或 `Public`。
`Allowed file types / 允许的文件类型`写入浏览器 `accept` 属性,例如 `.pdf,image/*`;它是用户体验提示,不替代服务器端校验。

上传模式

行为适用情形
`Auto`(默认)在问卷 deployment 路由中使用既有的问卷附件 API;其他表单仅把浏览器 `File` 保留给 Action Code。旧表单的兼容默认值。
`Platform form shared media`上传至 Core 共享媒体库,控件值为资产 ID/引用对象。普通表单、商城后台表单、需要统一下载与容量限制的模块。
`Survey attachment`强制使用问卷部署附件 API。没有 deployment 上下文会显示错误。仅问卷。
`Third-party custom API`以 `multipart/form-data` 调用你配置的 API。独立模块、外部文件服务、业务专属审批或扫描流程。
`Manual (Action Code)`不立即上传;Action Code 从 `args.files[controlId]` 读取浏览器文件。CSV 导入、临时解析、已有的客户端业务代码。

普通表单的共享媒体模式

选择 `Platform form shared media` 后,控件会调用:

POST /api/forms/{formId}/media
GET  /api/forms/{formId}/media/{mediaId}/reference
GET  /api/forms/{formId}/media/{mediaId}

上传与下载均复用该表单的 **read ACL**;匿名表单只允许在表单处于启用状态时访问。服务器从路由中的 `formId` 推导媒体 owner,浏览器不能提交任意 owner。表单绑定到字符串数据库列时,`FileUpload` 的引用对象会自动映射为稳定的媒体 ID,而不是把整段 JSON 写入列。

上传请求同时携带 FileUpload 的 `controlId`。Core 会确认该 ID 在表单架构中确实是 `Type = File` 且 `Upload mode = Platform form shared media` 的控件;这避免拥有表单阅读权限的用户绕过表单配置、向任意表单上传文件。

`Private` 文件只能走上面的 ACL 下载地址;`Public` 文件还可通过公开的 `/media-assets/{id}/{optionalName}` 地址被读取。公开应只用于本来就应公开的商品图片、下载资料等内容。

上传后、用户尚未提交业务记录之前,Core 会建立一个受保护的临时表单上传引用,避免通用媒体删除 API 错删该资产。记录级媒体引用由将来的业务生命周期服务接管;因此当前不要直接调用通用媒体删除 API 删除由 FileUpload 上传的文件。

第三方 API 模式约定

内置控件会以 `multipart/form-data` 发送:

file       二进制文件
formId     当前 FormPlatform 表单 ID
controlId  FileUpload 控件 ID

最小成功响应可以是 JSON 字符串媒体 ID;推荐返回完整对象,以便控件立即显示名称和下载链接:

{
  "id": "9a63b4d4-...",
  "fileName": "contract.pdf",
  "fileSize": 245760,
  "contentType": "application/pdf",
  "downloadUrl": "/api/your-module/files/9a63b4d4-..."
}

第三方 API 必须自行完成:认证/表单权限、大小与扩展名/MIME 校验、病毒扫描(若需要)、对象归属检查及下载授权。跨域 API 还必须正确配置 CORS。若业务表单映射到字符串列,API 返回对象时同样只会持久化其中的 `id`。

商城与模块服务端接入

商城商品图库、文档模块等不应新建全局二进制表。它们应通过 SDK 的 `IMediaService` 使用自己的 owner(如 `commerce`),并在保存商品/文档记录后调用 `ReplaceReferencesAsync` 写入记录级引用。这样通用删除 API 可以检测仍在使用的资产。上面的 `ProductImageService` 示例可直接作为后端模式参考。