Gitea Issue 侧边栏“下拉 + 列表”组合组件(issue-sidebar-combo)实现解析
本文围绕 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.ts 中 IssueSidebarComboList 构造函数可以看到,组件初始化时会从容器上读取三个属性,并对取值做合法性校验:
| 属性 | 取值 | 作用 |
|---|---|---|
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.checked的data-value)。
三、同步逻辑:页面加载与选择变更
文档定义的同步规则在 init() 中逐条落地:
1. 页面加载时(init())
- 若下拉菜单里已存在 checked 的 item(即模板渲染时就标记了选中态),则不做任何同步——此时认为下拉菜单与列表已经一致,代码只把已勾选值记为
initialValues基线; - 若下拉菜单中没有 checked item 但
combo-value有值(且不为'0'),则按逗号拆分 value,为菜单中对应的data-valueitem 补上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算法:逐项比对初始值与当前值——初始有、现在没有的,POSTaction=detach&id=...(逐个移除);现在新选中的,POSTaction=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.ts 的 initRepoIssueSidebar():通过 registerGlobalInitFunc('initRepoIssueSidebar', ...) 注册全局初始化函数,在侧边栏容器内用 queryElems(elSidebar, '.issue-sidebar-combo', ...) 为每个 combo 实例化一个 IssueSidebarComboList 并调用 init();同一入口还初始化了分支选择器、截止日期表单、依赖项搜索下拉等其他侧边栏功能。
六、滚动菜单与键盘操作的前置条件
文档最后一条约束:“使用 scrolling menu 时,item 必须处于同一层级,否则键盘操作(ArrowUp/ArrowDown/Enter)不可用”。五份模板中,菜单内容都放在 .menu 内的 .scrolling menu 容器里平铺展开(label_list.tmpl、milestone_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-value 与 href 正确、scrolling menu 保持扁平层级,即可自动获得选中同步、空态提示、loading 反馈与时间线局部刷新等全部能力。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00