首页
/ Gitea Issue 侧边栏“下拉 + 列表”组合组件(issue-sidebar-combo)实现解析

Gitea Issue 侧边栏“下拉 + 列表”组合组件(issue-sidebar-combo)实现解析

2026-09-06 18:41:58作者:滕妙奇

本文围绕 Gitea 仓库中的组件设计说明文档 repo-issue-sidebar.md 展开,完整解读 Issue/Pull Request 页面右侧侧边栏中“下拉菜单 + 选中项列表”(sidebar combo)的 HTML 结构、data-* 配置属性、页面加载与选择变更时的双向同步逻辑,以及它与后端 attach/detach 接口的交互方式;并结合 IssueSidebarComboList 源码与五份侧边栏模板(label、assignee、reviewer、milestone、project),还原该组件从渲染、用户交互到局部刷新页面的完整调用链。

一、什么是 issue-sidebar-combo

Gitea 的 Issue / Pull Request 详情页右侧有一个“侧边栏”区域,用于管理标签、指派人、审查人、里程碑、所属项目等属性。这些区块并不是各自为政的独立控件,而是复用了同一套“组合控件”(combo)模式:上面是一个 Fomantic UI 的下拉菜单(dropdown)供候选项选择,下面是一个列表(list)展示当前已选中的项并提供相关操作。

组件文档 repo-issue-sidebar.md 给出的标准 DOM 结构如下:

<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="...">
  <input class="combo-value" name="..." type="hidden" value="...">
  <div class="ui dropdown">
    <div class="menu">
      <div class="item clear-selection">clear</div>
      <div class="item" data-value="..." data-scope="...">
        <span class="item-check-mark">...</span>
        ...
      </div>
    </div>
  </div>
  <div class="ui list">
    <span class="item empty-list">no item</span>
    <span class="item">...</span>
  </div>
</div>

各部分职责:

  • .issue-sidebar-combo:组合控件的容器,所有 data-* 配置都挂在它上面,JS 逻辑以它为初始化单元;
  • .combo-value:一个隐藏的 <input>,是选中状态的“单一事实来源”。当选中项发生变化时,该 input 的 value 会被同步更新为逗号分隔的选中项值列表;
  • .ui.dropdown:候选项下拉菜单。.item.clear-selection 是固定的“清除全部选择”入口;每个候选项用 data-value 标识其业务 ID,可选 data-scope 表达“互斥分组”;.item-check-mark 是勾选标记,仅在 item 带有 checked 类时可见(repo.css 第 70 行有对应规则:.issue-sidebar-combo > .ui.dropdown .item:not(.checked) .item-check-mark 被隐藏);
  • .ui list:展示“已选中项”的列表区,empty-list 项用于空状态占位。该列表是可选的——没有 .ui list 时,单个 dropdown 也能独立工作,同样可以选项目并更新到后端。

二、容器上的三个 data-* 配置

结合 repo-issue-sidebar-combolist.tsIssueSidebarComboList 构造函数可以看到,组件初始化时会从容器上读取三个属性,并对取值做合法性校验:

属性 取值 作用
data-selection-mode single / multiple 选择模式。构造函数中若不在 ['single', 'multiple'] 内会直接抛错
data-update-algo diff / all 更新算法。构造函数中若不在 ['diff', 'all'] 内会直接抛错
data-update-url URL(可缺省) 选中项变化时用于调用后端 attach/detach(或整组提交)的接口地址;缺省时只更新前端 UI,不发请求

构造函数同时用 :scope > 精确定位三个直接子节点(.ui.dropdown.ui.list.combo-value),保证一个 combo 容器只绑定一套结构;并额外缓存了页面上 .issue-content-left(主内容区)与 .issue-content-right(侧边栏)两个节点,供后续“局部刷新页面”使用。

选择模式的两种语义(与文档一致,源码实现见 onItemClick):

  • single:同一时间只能选中一项,选中后立即触发更新(onChange() 中先 doUpdate() 再隐藏菜单);
  • multiple:可以勾选多项,点击只切换 checked 类、不立即发请求,延迟到下拉菜单隐藏时才统一更新(onHide() 中调用 doUpdate())。

data-scope:同组互斥

文档指出:“data-scope 相同的 item 之间只允许同时选中一个”。源码实现:点击带 data-scope 的 item 时,先查询同 scope 下已有的 checked item——如果点中的正是它,则取消勾选(即同组支持“再次点击取消”);否则先移除同 scope 所有项的 checked,再把当前项置为选中。这在 label_list.tmpl 中有典型应用:标签的“互斥作用域”(ExclusiveScope)用于同类标签(如优先级)之间互斥,模板还为不同作用域的 divider 标注了 data-scope 以保持菜单分层。

其他交互细节

  • data-can-change:item 可带该属性声明是否允许变更(reviewer_list.tmpl 对“不可变更”的审查人渲染 data-can-change="false" 并附 tooltip 说明),onItemClick 开头会据此拦截点击;
  • clear-selection:点击“清除”项会移除所有 checked、把 combo-value 置空并触发 onChange()
  • 每次点击后都会执行 this.elComboValue.value = this.collectCheckedValues().join(','),保持隐藏 input 与勾选状态一致(collectCheckedValues() 直接收集 .menu > .item.checkeddata-value)。

三、同步逻辑:页面加载与选择变更

文档定义的同步规则在 init() 中逐条落地:

1. 页面加载时(init()

  • 若下拉菜单里已存在 checked 的 item(即模板渲染时就标记了选中态),则不做任何同步——此时认为下拉菜单与列表已经一致,代码只把已勾选值记为 initialValues 基线;
  • 若下拉菜单中没有 checked itemcombo-value 有值(且不为 '0'),则按逗号拆分 value,为菜单中对应的 data-value item 补上 checked 类;
  • 接着若存在 .ui list 且其 data-combo-list-inited !== 'true',就调用 updateUiList() 把选中项克隆到列表中。data-combo-list-inited="true" 是一种逃生口:表示列表内容由模板自行渲染好(比如项目卡片带列选择器的 project_list.tmpl 第 48 行),JS 不应再覆盖;
  • 文档同时强调:当“没有下拉项、但列表需要展示预定义项”的场景,combo-value 应为空,否则初始化逻辑会误触发。

2. 下拉菜单隐藏时

  • multiple 模式下,Fomantic dropdown 的 onHide 回调触发 doUpdate(),把本轮所有勾选变更一次性同步到列表与后端;
  • single 模式则在点击时立即更新。

Fomantic 的配置(init() 尾部)也值得注意:action: 'nothing'(按 Enter 不会关闭菜单,方便多选场景)、fullTextSearch: 'exact'hideDividers: 'empty',菜单内的搜索框配合 hideDividers: 'empty' 可以隐藏“搜空”的分组。

克隆列表时的两个约束

updateUiList()L71-L84)先从菜单中找到对应 item 再 cloneNode(true) 克隆进列表,并删掉克隆体里的 .item-check-mark.item-secondary-info。文档对此有两点告诫,在代码中都得到印证:

  • 菜单 item 的 href 必须是正确链接,否则同步(克隆)到列表后的链接也会是错的——因为列表项直接复用了菜单项的锚点;
  • 克隆后通过 toggleElem(elEmptyTip, !hasItems) 控制空状态提示的显隐。

四、与后端交互:diff / all 两种更新算法与局部刷新

选中项变化后,doUpdate() 先比较 initialValues 与当前勾选值,完全相同则直接返回;不同则:

  • data-update-url:只调用 updateUiList() 更新前端;
  • data-update-url:调用 updateToBackend()sendRequestToBackend(),成功后执行局部页面刷新(而非整页 reload)。

sendRequestToBackend()L100-L119)按 data-update-algo 分两条路径:

  • diff 算法:逐项比对初始值与当前值——初始有、现在没有的,POST action=detach&id=...(逐个移除);现在新选中的,POST action=attach&id=...(逐个添加)。每次请求都会检查 resp.ok,任一步失败立即返回该响应中止后续操作;
  • all 算法:只发一次请求,把全部选中值以逗号拼接后作为 id 参数提交,后端整体替换。

updateToBackend() 还会给整个侧边栏加 is-loading 类作为视觉反馈;请求失败时通过 showErrorToast 提示,不破坏当前 UI。

局部刷新:只换侧边栏,时间线增量同步

刷新逻辑在 reloadPagePartially():重新 GET 当前页面 URL,解析返回的 HTML 文档后:

  • 用新文档中的 .issue-content-right 整体替换旧侧边栏(注释说明:右侧只有一堆 dropdown 和 list,整体替换是安全的);
  • 左侧主内容区不能整体替换(会丢失输入框焦点等状态),只能做已知时间线条目的增量同步,这正是 syncIssueMainContentTimelineItems()L8-L37)的职责:以 id="timeline-comments-end" 为时间线锚点,对新旧两端的 .timeline-item[id] 逐一比对——旧内容中的 event 类条目若在新内容中消失则删除;新内容中的 event 条目若已存在则替换(因为“添加/移除标签”这类操作可能合并为一个 event 条目,内容会变化);其余新条目插入到时间线末尾锚点之前。

这段同步逻辑有专门的行为测试 repo-issue-sidebar-combolist.test.ts,覆盖 InsertNew(新条目插入到 timeline-comments-end 之前)与 Sync(event 条目替换、非 event 条目保留、消失的 event 条目删除)两种场景。

五、五个真实侧边栏模板的对照

仓库中 issue-sidebar-combo 的实际使用者共有五份模板(外加项目列的一个嵌套 combo),对照如下(update-url 仅在编辑已存在的 Issue/PR 时渲染,新建页面上不发送请求、只填表单隐藏字段):

模板 控件 selection-mode update-algo data-update-url(既有 Issue)
label_list.tmpl 标签 multiple diff {repo}/issues/labels?issue_ids={id}
assignee_list.tmpl 指派人 multiple diff {repo}/issues/assignee?issue_ids={id}
reviewer_list.tmpl 审查人 multiple diff {repo}/issues/request_review?issue_ids={id}
milestone_list.tmpl 里程碑 single all {repo}/issues/milestone?issue_ids={id}
project_list.tmpl 项目 multiple all {repo}/issues/projects?issue_ids={id}
同上(嵌套的项目列选择器) 项目列 single all {repo}/issues/projects/column?issue_id={id}

规律一目了然:允许多选的属性(标签、指派人、审查人、项目)使用 diff 逐项 attach/detach 或 all 整体提交;天然是“单个值”的属性(里程碑、项目列)使用 single + all。其中审查人列表展示了 data-can-change 与模板内预置 checked 状态({{if .Requested}}checked{{end}},此时页面加载走“已有 checked item、不做同步”分支)的用法;项目列表的卡片区则用 data-combo-list-inited="true" 声明“列表由服务端渲染,前端勿覆盖”。

这些控件的初始化入口在 repo-issue-sidebar.tsinitRepoIssueSidebar():通过 registerGlobalInitFunc('initRepoIssueSidebar', ...) 注册全局初始化函数,在侧边栏容器内用 queryElems(elSidebar, '.issue-sidebar-combo', ...) 为每个 combo 实例化一个 IssueSidebarComboList 并调用 init();同一入口还初始化了分支选择器、截止日期表单、依赖项搜索下拉等其他侧边栏功能。

六、滚动菜单与键盘操作的前置条件

文档最后一条约束:“使用 scrolling menu 时,item 必须处于同一层级,否则键盘操作(ArrowUp/ArrowDown/Enter)不可用”。五份模板中,菜单内容都放在 .menu 内的 .scrolling menu 容器里平铺展开(label_list.tmplmilestone_list.tmpl 等均为这一结构);分组标题用 div.header / div.divider 实现而非嵌套子菜单,正是为了保证 Fomantic 的下拉键盘导航可以在“扁平”的 item 序列上正确移动焦点。改造模板时若引入嵌套层级,应优先保留该约束,或自行处理焦点逻辑。

小结

issue-sidebar-combo 用一份轻量文档 + 一个约 200 行的 TS 类,把 Gitea Issue 侧边栏五个不同语义的属性区块统一成了“隐藏 input 为状态源、dropdown 为选择器、list 为只读镜像、diff/all 两种后端提交策略、局部刷新页面”的可复用模式。对需要新增侧边栏属性控件的开发者来说,路径是清晰的:复制任一 templates/repo/issue/sidebar/*_list.tmpl 的结构,按上表选对 data-selection-mode / data-update-algo / data-update-url,保证菜单 item 的 data-valuehref 正确、scrolling menu 保持扁平层级,即可自动获得选中同步、空态提示、loading 反馈与时间线局部刷新等全部能力。

登录后查看全文
热门项目推荐
相关项目推荐