Files
Navius/docs/frontend-architecture.md
CNWei 039ea70edf feat: 实现导航页基础功能
- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
2026-09-19 23:17:36 +08:00

26 KiB
Raw Permalink Blame History

Navius Flutter 前端结构与实现说明

1. 范围与前提

本文档描述 frontend/(Flutter Web 前端)的目录结构、分层理由,以及它如何从静态原型落地为可维护的产品代码。

约束条件:

  • 目标平台只有 Web,不做移动端 / 桌面端适配。
  • 数据来自 Rust 后端的 GET /api/nav(本地 mock 保留给测试用)。
  • 不修改 backend/(Rust),只在架构上为将来接入后端留好接口位置。
  • 视觉与交互基准是项目根目录的 index3.html,逐条对齐、不自行发挥。

整理目标按优先级排列:可读(新人能顺着目录找到代码)→ 可测(核心逻辑不依赖浏览器)→ 可扩展(接后端时改动面最小)。

逐条核对(每一条 CSS 规则、每一段 JS 对应到哪个文件)见 index3-alignment.md;本文讲结构与取舍。

2. 基准:根目录的 index3.html

index3.html 是一份独立静态原型,也是本轮实现的唯一视觉基准:

┌──────────────┬──────────────────────────────────────────────┐
│  Suxing Nav  │  ☰   🔍 搜索框            ← 750px 居中顶栏    │
│              │                                              │
│  常用推荐     │  ▍常用推荐                                   │
│  素材资源  ›  │  ┌──────┐ ┌──────┐ ┌──────┐                  │
│    ├ UI 设计  │  │ 卡片  │ │ 卡片  │ │ 卡片  │                │
│    ├ 图标库   │  └──────┘ └──────┘ └──────┘                  │
│    └ 高清壁纸 │                                              │
│  开发工具  ›  │  ▍素材资源 - UI 设计                          │
│    ├ 前端框架 │  ┌──────┐ ┌──────┐                           │
│    └ 在线 API │  └──────┘ └──────┘                           │
│              │                                              │
│  ──────────  │                                        ⬆ 回到 │
│  提交网站     │                                          顶部 │
└──────────────┴──────────────────────────────────────────────┘
     230px(可折叠到 64px)

它是一份原型而不是成品:搜索框没有任何行为、导航的 active 是写死的、折叠后只剩一排空方块、导航里七个锚点有五个没有对应分区、整份文件没有任何媒体查询。这些差距在本轮实现中全部补齐,补齐内容见第 6 节。

3. 最终目录结构

仓库整体:

Navius/
├── index.html                  # 早期静态原型(历史版本,保留作参考)
├── index3.html                 # 当前视觉基准(静态原型,不被构建流程引用)
├── backend/                    # Rust 后端,本轮未改动
│   ├── Cargo.toml
│   └── src/main.rs
├── docs/                       # 项目级文档
│   └── frontend-architecture.md
└── frontend/                   # Flutter Web 前端
    ├── lib/                    # 应用代码(见下方展开)
    ├── test/                   # 测试代码,目录结构与 lib/ 镜像对应
    ├── web/                    # Web 平台资源(index.html、manifest、图标)
    ├── android/ ios/ linux/ macos/ windows/   # Flutter 脚手架生成,Web 目标不使用
    ├── analysis_options.yaml   # 静态分析与 lint 规则
    └── pubspec.yaml            # 依赖与项目描述

lib/ 展开:

lib/
├── main.dart                                       # 入口:runApp 一行
├── app.dart                                        # 应用根组件:主题配置 + 依赖装配
│
├── core/                                           # 与业务无关的基础设施
│   ├── constants/
│   │   ├── app_sizes.dart                          # 尺寸与动效常量
│   │   └── app_breakpoints.dart                    # 网格列数计算(等价 CSS auto-fill)
│   ├── services/
│   │   ├── url_opener.dart                         # 打开链接的抽象接口
│   │   └── web_url_opener.dart                     # Web 实现(唯一依赖 package:web 的文件)
│   ├── theme/
│   │   ├── app_colors.dart                         # 颜色与阴影
│   │   ├── app_text_styles.dart                    # 语义化文字样式
│   │   └── app_theme.dart                          # ThemeData
│   └── utils/
│       └── text.dart                               # 取首字符(中文 / emoji 安全)
│
└── features/                                       # 一个功能一个目录
    └── home/                                       # 首页:导航站主界面
        ├── data/
        │   ├── home_repository.dart                # 仓库契约(读 / 提交 / 编辑 / 删除,异步)
        │   ├── http_home_repository.dart           # 后端实现:四个接口 + 映射成前端模型
        │   ├── mock_home_data.dart                 # mock 数据(3 个一级导航 / 2 个分区 / 5 个站点)
        │   └── mock_home_repository.dart           # mock 仓库实现(测试用)
        ├── domain/models/
        │   ├── nav_node.dart                       # 侧栏导航节点(叶子 / 可展开分组)
        │   ├── link_item.dart                      # 站点条目模型(含搜索匹配规则)
        │   ├── link_section.dart                   # 分区模型(含分区过滤)
        │   └── home_data.dart                      # 首页聚合根(导航树 + 分区 + 跨分区搜索)
        └── presentation/
            ├── pages/home_page.dart                # 首页:布局、状态与交互
            └── widgets/
                ├── sidebar_nav.dart                # 侧栏:折叠、层级导航、提交入口
                ├── top_bar.dart                    # 顶部控制栏:折叠按钮 + 搜索框
                ├── link_search_field.dart          # 搜索框(含聚焦光晕)
                ├── link_section_view.dart          # 分区标题 + 自适应网格
                ├── link_card.dart                  # 站点卡片
                └── back_to_top_button.dart         # 右下角圆形回到顶部

test/ 展开(与 lib/ 一一对应):

test/
├── core/constants/app_breakpoints_test.dart         # 断点与网格列数
├── features/home/
│   ├── data/mock_home_repository_test.dart          # 导航树、数据自洽性与搜索规则
│   └── presentation/
│       ├── home_page_test.dart                      # 布局 / 折叠 / 子菜单 / 搜索 / 联动
│       └── link_card_test.dart                      # 卡片渲染、点击、悬停
└── helpers/fake_url_opener.dart                     # 假实现,避免测试真的弹浏览器

4. 依赖方向与数据流

依赖方向是单向的,箭头表示「可以引用」:

main.dart → app.dart
                │
                ▼
           features/home  ──▶  core/

两条硬规则:

  1. core/ 不引用 features/。
  2. domain/ 不引用 data/ 与 presentation/。

数据流:

后端 GET /api/nav ──▶ { site: { name, searchHint, submitUrl }, groups: [ … ] }
   │                                        ▲
   │                                        │ POST /api/sites(提交)
   │                                        │ PATCH /api/sites/{id}(编辑)
   │                                        │ DELETE /api/sites/{id}(删除)
   ▼                                        │   ← 这三个接口页面上暂时不用
HttpHomeRepository ──实现──▶ HomeRepository(读 / 提交 / 编辑 / 删除)
   │  把分组树映射成前端模型:分组 → 侧栏导航;有网站的组 → 内容分区;
   │  每个网站带 editable(页面暂时不据此渲染任何入口)
   ▼
HomePage State:_load() 拿到 HomeData,并在加载中 / 失败(可重试)/ 完成三种状态间切换
   │  (页面持有交互状态:当前分区、侧栏折叠、已展开的分组)
   ▼
展示组件:只接收数据 + 回调,不自己取数、不自己跳转

5. 设计令牌:从 CSS 到常量

index3.html 用 CSS 变量组织样式,这里逐条映射到 core/theme 与 core/constants,组件只引用语义名,不再出现十六进制色值或魔法数字。

index3.html 中的来源 取值 Flutter 对应
--bg-color #f7f9fb AppColors.background
--card-bg #ffffff AppColors.surface
--primary-color #3b82f6 AppColors.primary
--text-main #1e293b AppColors.textPrimary
--text-sub #64748b AppColors.textSecondary
--border-color #e2e8f0 AppColors.border
--shadow 0 4px 6px -1px rgba(0,0,0,.05) + 0 2px 4px -1px rgba(0,0,0,.03) AppColors.cardShadow
--shadow-hover 0 10px 15px -3px rgba(0,0,0,.08) + 0 4px 6px -2px rgba(0,0,0,.04) AppColors.cardShadowHover
.nav-link.active #eff6ff AppColors.primaryFaint
.nav-link:hover #f8fafc AppColors.navItemHover
.submit-btn:hover #f1f5f9 AppColors.navItemHoverDeep
.arrow-icon #94a3b8 AppColors.navArrow
.card-icon 底色 #f1f5f9 AppColors.iconSurface
.search-box:focus 光晕 0 0 0 3px rgba(59,130,246,.15) AppColors.focusRing + AppSizes.focusRingWidth
--sidebar-width 230px AppSizes.sidebarWidth
--sidebar-collapsed-width 64px AppSizes.sidebarCollapsedWidth
.sidebar 内边距 20px 12px 12px 12px sidebarPaddingTop / Horizontal / Bottom
折叠态内边距 20px 8px 12px 8px sidebarCollapsedPaddingHorizontal
折叠动画 width .3s cubic-bezier(.4,0,.2,1) sidebarTransition + Curves.fastOutSlowIn
.sidebar-header height:40px; margin-bottom:24px,logo 居中 sidebarHeaderHeight、sidebarHeaderGap
.logo 18px / 700 AppTextStyles.logo
.logo-icon 20px / 800 AppTextStyles.logoIcon
.nav-link padding: 11px 16px,圆角 8px,14px / 500 navItemPaddingVertical/Horizontal、navItemRadius、AppTextStyles.navItem
.nav-item-wrapper margin-bottom: 4px navItemGap
折叠态 .nav-link padding: 12px 0 + 居中 navItemCollapsedPaddingVertical
.sub-menu padding-left: 16px,.3s 高度动画 subMenuIndent、subMenuTransition
.sub-menu .nav-link padding: 8px 16px,13px subNavItemPaddingVertical/Horizontal、AppTextStyles.subNavItem
.sidebar-footer 上边框 + padding-top:12px; margin-top:8px sidebarFooterPaddingTop、sidebarFooterMarginTop
.submit-btn padding: 10px 16px,圆角 8px submitButtonPaddingVertical/Horizontal
.main-content padding: 24px 40px 40px 40px contentPaddingTop / Horizontal / Bottom
.top-bar max-width:750px; gap:16px; margin-bottom:36px,水平居中 topBarMaxWidth、topBarGap、topBarMarginBottom
.toggle-btn 44×44,圆角 12px toggleButtonSize、toggleButtonRadius
.search-box height:44px; padding: 0 20px,圆角 12px,15px searchHeight、searchPaddingHorizontal、searchRadius、AppTextStyles.searchInput
.section-title 16px / 600,::before 为 4×16 竖条 AppTextStyles.sectionTitle、sectionAccentWidth/Height/Radius/Gap
分区下间距 / 网格间距 36px / 16px sectionGap、gridGap
.grid repeat(auto-fill, minmax(220px, 1fr)) gridMinItemWidth + AppBreakpoints.gridColumnsFor
.card 圆角 10px,内边距 14px 16px,悬停 translateY(-2px) cardRadius、cardPaddingVertical/Horizontal、cardHoverLift
.card-icon 36px,圆角 8px,右侧间距 12px cardIconSize、cardIconRadius、cardIconGap
.card-title / .card-desc 14px / 600 / 12px,单行省略 AppTextStyles.cardTitle / cardDesc
.back-to-top 44×44 圆形,距右下角 30px,悬停 translateY(-3px) backToTopSize、backToTopOffset、backToTopHoverLift
transition: 0.2s 200ms AppSizes.transition

6. 明确不实现的原型缺口

当前实现是 index3.html 的逐条还原,没有补任何原型之外的行为。原型里没做的,这里也不做:

原型的状态 当前实现
搜索框没有任何 JS 只接收输入,不过滤内容
active 写死在「常用推荐」上 高亮固定不动
没有媒体查询 侧栏始终 230px(可手动折到 64px),主内容内边距始终 24/40/40/40
#icon、#stock、#dev、#api、#submit 没有对应元素 点击后没有任何反应
只有 #common、#design 两段内容 内容区就是两段、五张卡片

唯一必须偏离 CSS 的地方是分区标题的换行:.section-title 是 display:flex, 浏览器会把过长文字折行,而 Flutter 的 Row 会直接溢出报错, 所以标题用 Flexible 包住并单行省略。这条差异会随着标题文案的调整而消失。

响应式规则

视口宽度 布局
≥ 900px 侧栏默认展开(230px)
< 900px 主内容内边距 40 → 20,侧栏默认折叠(64px)

断点取 900 而不是常见的 768:侧栏 230 + 主内容左右内边距 + 一个 220 的卡片, 再留出可读留白,900 是这套栅格开始舒展的下限。规则写成纯函数 AppBreakpoints.layoutFor(width),可以被单元测试覆盖。

「默认折叠」而不是「强制折叠」:用户手动切换过之后, _collapsedOverride 就接管了控制权,拖窗口不会再覆盖用户的意愿。

网格列数

columns = floor((availableWidth + gap) / (minItemWidth + gap))

这与 CSS 的 repeat(auto-fill, minmax(220px, 1fr)) 等价:比如可用宽度 456 时正好放下两列(220×2 + 16),455 时退回一列。测试里有一个用例专门验证这条边界。

7. 为什么这样整理

7.1 为什么 main.dart 只留几行

入口文件的职责只有一个:把根组件挂到 Flutter 引擎上。任何额外逻辑(主题、依赖装配、路由)放进 main.dart 都会让「程序从哪里开始」变得模糊,也没法在测试里绕开它。

7.2 为什么是 core + features,而不是 models/ services/ widgets/

按技术类型平铺,在项目只有一两个页面时看着整齐;页面一多就会出现「改一个功能要翻五个目录」,而且同名文件互相污染。

按功能切分后,features/home/ 里的东西只服务于首页,删掉整个目录不影响其它部分。core/ 则只放与业务无关的东西——主题、尺寸常量、断点计算、取首字符、打开链接的能力。判断标准很简单:如果它能被原样复制到另一个 App 里,它就属于 core。

7.3 为什么功能内部再分 data / domain / presentation

  • domain/ 是模型与规则,不依赖 Flutter、不依赖网络,最容易测试。
  • data/ 是「数据从哪来」:后端实现、mock 实现,以及它们共同实现的仓库契约。
  • presentation/ 是「数据怎么显示」,包含页面与组件。

好处是接后端时改动落点明确:只加一个 data/ 下的实现类,domain/ 与 presentation/ 基本不动。

7.4 为什么导航用一棵树(NavNode)而不是两个列表

侧栏里同时存在两种条目:直接跳转的(常用推荐)和可展开的(素材资源、开发工具)。 如果拆成「一级列表 + 二级字典」,页面就要在两份数据之间来回对照, 「某个分区属于哪个分组」这种问题会散落在多处。

用同一棵树表达,NavNode.leaves 能把树拍平成内容区需要的顺序, HomeData.groupIdOf(id) 能回答「这个分区属于哪个分组」, isGroup 决定渲染成跳转项还是展开项。树是唯一真相。

7.5 为什么搜索规则写在 domain 而不是页面里

HomeData.search() 与 LinkItem.matches() 是纯函数,不依赖任何 Widget。这样「搜什么、怎么算命中、命中后空分区要不要保留」这几条规则可以直接被单元测试覆盖,不必挂载界面。放到页面里就得先 pump 一整棵组件树才能验证一条过滤规则。

7.6 为什么要有 Repository 接口 + Mock 实现

页面只依赖接口,不依赖具体数据源。这换来两件事:接 Rust 后端时新增一个实现类即可;测试里可以注入任意数据集,不用为「测试专用数据」改动生产代码。

7.7 为什么仓库返回 Future

接上后端之前,fetchHomeData 是同步的:mock 数据是恒定常量,套一层 Future 只会凭空多出 loading 与 error 两个分支。

接上 GET /api/nav 之后这两个状态变成真实存在的——网络会慢、会断、后端会重启—— 所以契约改成 Future<HomeData>,页面里相应地有了加载中、加载失败(带重试)、 加载完成三种状态。改动只落在 data/ 与页面的取数那一处,模型和展示组件没动。

7.8 为什么把「打开链接」抽成 UrlOpener

卡片直接调用 web.window.open 会让组件无法在测试里验证,也会让平台相关代码散落在 UI 里。抽象成接口后,package:web 只出现在 web_url_opener.dart 一个文件里,测试可以注入假实现,断言「点了哪张卡片、打开了哪个地址」。

7.9 为什么不做滚动联动

侧栏的 active 只由点击决定,滚动内容区不会改动侧栏。

曾经实现过一版「滚到哪个分区、侧栏就高亮哪一项」的联动, 结果是滚动右侧时左侧一直在闪,观感像有只鼠标在侧栏上一路划过去。 原因有两层:一是内容区每个分区只有一百多像素高,连续滚动时高亮会 一个接一个地亮起;二是当时的路由是「滚动监听 → 页面 setState → 整棵子树(含侧栏)重建」,为了压住这个抖动又叠了去抖、 激活线、程序化滚动的抑制标志,越修越复杂。

最后直接去掉联动。理由:

  1. 左侧栏和右侧内容区没有因果关系,滚动内容区不该触发侧栏重建。 去掉后这一点是结构性的——initState 里不再给 ScrollController 挂监听,滚动只重绘内容区,侧栏一个 widget 都不会重建。
  2. 这个导航站的分区标题本身就在页面上,用户不需要侧栏替他回答 「我在哪一节」。
  3. 原型里 active 本来就是静态的,去掉联动反而更接近给定设计。

测试里有一条用例守住这条边界:滚动到内容底部后, 断言侧栏第一行的 AnimatedContainer 仍是同一个 widget 实例 (没有重建),且高亮位置不变。

7.10 为什么折叠态显示首字,而不是照搬原型的空白

原型折叠后把 .nav-text 全部 display:none 且没有准备图标, 侧栏会变成一列没有信息的空方块,用户没有任何线索知道点哪个是哪个。

改成显示标题首字,是在不引入图标资源的前提下、与原设计语言最一致的做法—— logo 折叠后显示的也正是站点名的首字「S」。同理,取首字用 runes 而不是 substring,中文与 emoji 才不会被截成半个字符。

7.11 为什么子菜单收起时把子项从树里移除

CSS 的 max-height: 0 只是视觉上收起了子菜单,DOM 里那些链接还在: 屏幕阅读器仍会读到,键盘 Tab 也会跳进去。Flutter 里对应的做法是用 AnimatedSize 配合「关掉时不渲染子项」,既保留高度渐变的观感, 又让语义树与点击命中都保持干净。

7.12 为什么组件只发回调、状态集中在页面

搜索词、当前分区、侧栏折叠状态、已展开的分组都由页面持有, 组件只负责显示并向上发回调。这样同一个数据不会有两个真相来源。 只有纯本地的、与外界无关的瞬时状态(悬停)才允许留在组件内部。

7.13 为什么把颜色、尺寸、文字样式集中

组件里散落十六进制颜色和魔法数字,会让「改主题色」变成全局搜索替换。现在颜色在 app_colors.dart、尺寸在 app_sizes.dart、文字样式在 app_text_styles.dart,组件只引用语义名。要改主色,只改一处。

7.14 为什么测试目录镜像 lib/

test/features/home/presentation/link_card_test.dart 对应 lib/features/home/presentation/widgets/link_card.dart。看测试路径就能猜出被测文件的位置,避免测试文件越堆越多之后找不到归属。

7.15 为什么保留看起来「没用」的 cupertino_icons

项目不使用 Cupertino 图标,但 Flutter 构建时会引用 CupertinoIcons 字体,移除依赖会导致构建出现字体缺失警告。保留模板自带的版本,是为了构建输出干净。

7.16 为什么坚持「注释写为什么,不写是什么」

「给变量赋值」这类注释没有信息量,代码本身就能表达。真正值得写下来的是取舍:为什么断点取 900、为什么点击时要关掉滚动联动、为什么折叠态用首字。 这类信息一旦丢失,后来者很容易「好心」改回去。

8. 编码约定

analysis_options.yaml 在 flutter_lints 之上补充了这些规则:

  • strict-raw-types:禁止裸泛型(如 List 而非 List<String>)。
  • always_declare_return_types:函数与方法必须显式声明返回类型。
  • avoid_print:禁止遗留调试输出。
  • prefer_single_quotes:统一单引号,减少无意义的 diff。
  • require_trailing_commas:配合 dart format 减少改动噪音。
  • sort_child_properties_last:child 固定在参数末尾。
  • unawaited_futures:未处理的 Future 必须显式处理。

未由 lint 强制、但代码里保持一致的几条:

  • 依赖方向由人工把关:新增共享代码前先判断它属于 core 还是某个功能。
  • 每个公开类与公开成员都写 /// 文档注释,且注释解释「为什么」。
  • 组件的构造函数以 super.key 开头,参数按「必填数据 → 可选数据 → 回调」排列。
  • 能用 const 的地方一律加 const。

9. 验证结果

检查 命令 结果
静态分析 flutter analyze 1 条告警(sidebar_nav.dart 里 _NavRow 的 super.key 从未被传入,见下方说明)
单元与组件测试 flutter test 47 个用例通过,6 个失败(都是侧栏实现与既有用例不一致,见下方说明)
Web 构建 flutter build web --no-wasm-dry-run 构建成功

上面这两处不是本轮对接引入的:e2cc3ac 那次提交重写了侧栏(行内改用 Container、 折叠态改回显示首字、logo 换成 AnimatedCrossFade),而 test/ 里那几条用例 描述的是重写前的行为。对接后端时没有动侧栏,也没有改这些用例。

测试覆盖的关键行为:

  • 网格列数:与 CSS auto-fill 规则一致,含「刚好放下两列」的边界。
  • 导航树:一级条目形态、叶子 id 与原型锚点一致、active 取第一个条目。
  • mock 数据自洽性:只有两个分区、共五张卡片,#icon 等原型里没有内容的锚点取不到分区,id 唯一、字段完整、地址合法。
  • 布局:侧栏展开常驻并渲染 logo 与三条导航、子菜单默认收起、两个分区与五张卡片全部渲染。
  • 侧栏折叠:折叠到 64px 且 logo 变首字、导航与提交文字都消失、再点可展开回来。
  • 分组子菜单:点击展开、再点收起、折叠状态下点分组会先展开侧栏。
  • 导航:高亮固定写在第一个条目上;点有对应分区的导航会滚过去且高亮不变;点 #icon 这类没有对应元素的导航滚动偏移不变。
  • 搜索框:输入后仍是两个分区、五张卡片(不过滤)。
  • 滚动:滚动主内容不会重建侧栏、也不会改动高亮;回到顶部把偏移归零。
  • 其它:点击卡片把对应 URL 交给 UrlOpener;点「提交网站」跳到后端配的地址;卡片渲染首字母、名称与描述,悬停上浮量等于 AppSizes.cardHoverLift。
  • 后端对接:请求打到 /api/nav;分组映射成侧栏导航树;只有挂着网站的分组进内容区; 子分组标题带父级前缀;网站顺序按后端给的排;非 200、非 JSON、缺 groups、 连不上后端分别给出对应提示;缺字段的单条数据被跳过而不是整页失败。
  • 加载状态:数据没回来时显示「正在加载…」;失败时显示原因和「重试」;点重试会重新取数并在成功后渲染内容。
  • 站点信息:站点名、搜索框提示、提交入口地址都来自后端 site 字段,前端不再写死。

10. 后续演进建议

按优先级:

  1. 做提交网站的界面。 后端已经有了 POST /api/sites(提交)与 /api/sites/push(推送), 前端侧栏底部的「提交网站」目前还是个空按钮——index3.html 里没有这个表单, 所以要先定表单长什么样再动手。
  2. 同步那 6 条侧栏用例。 要么把侧栏改回用例描述的行为,要么按现在的实现改用例。
  3. 换成真实图标。 目前卡片左侧是站点名首字母、折叠态是导航首字。若要改成站点图标,需要引入 favicon 抓取或本地图标资源,并处理加载失败的占位。
  4. 替换 PWA 图标。 web/icons/ 与 web/favicon.png 仍是 Flutter 默认图标,需要替换为项目自己的图标。
  5. 记住用户的偏好。 侧栏折叠状态、展开的分组、当前分区现在都是内存状态,刷新即丢失;可以写进 localStorage。
  6. 编辑模式。 导航站的下一步通常是允许用户自行增删分组与站点,那会需要把状态提升为可持久化的模型,并给 HomeRepository 增加写入接口。
  7. 深色模式。 现在的颜色令牌是单套浅色值,做法是在 AppColors 中按 Brightness 提供两套值,并在 AppTheme 里增加 dark 主题。