Files
DevFlow/docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md
T
lxy 8df4e33656 文档: 看板销账与登记 + 设计档六份 + 索引补录
- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
2026-10-10 08:09:25 +08:00

18 KiB

输入框列表预览与自动编号 — 交互设计

日期: 2026-10-10 | 状态: 设计稿(待拍板) | 需求锚点: docs/todo.md:701 条目 933 | 勘察: 只读实测(文件:行已核) 目标一句话: ChatInput 输入 1. / - 后,输入框内呈现"列表已就绪"的反馈(高亮/预览)并支持回车续写补号,对齐飞书消息输入框的顺手感,而当前是纯文本无反馈。 边界: 仅设计,不改码;不涉及后端/协议/落库。


一、现状勘察(锚点实测)

1.1 输入框本体

件 锚点 现状
编辑器 src/components/ai/ChatInput.vue:20-35 单个纯 <textarea class="ai-input" v-model="inputText">,rows="1",无富文本、无 overlay、无 mirror。事件: @keydown/@beforeinput/@input/@paste/@dragover/@drop
键处理 ChatInput.vue:814-910 handleKeydown 一条优先级长链:IME 守卫(817)→ Ctrl+R(822)→ 编辑态 Esc(829)→ 技能 Esc(836)→ chip Esc(843)→ 技能导航 ↑↓/Enter/Tab(850-872)→ 技能无匹配 Enter 拦截(875)→ mentions 导航 ↑↓/Enter/Tab(882-905)→ Enter 发送(906-909)
输入态互斥 ChatInput.vue:418-432 watch(inputText): 以 / 开头 → skillOpen;否则 detectMentionTrigger()。二者互斥(427-431)
/ 判据 ChatInput.vue:408 skillQuery = inputText.startsWith('/') ? slice(1) : '' —— 仅识别"整串以 / 开头",行首列表标记 1. 天然不与之撞
@ 判据 ChatInput.vue:439-467 从光标向前找最近 @,要求其前一字符为空/空白;遇空白即停
程序化改文 ChatInput.vue:509-532 已有成熟范式: beforeinput 快照旧文 + input 时公共前后缀差分定位编辑区间 → shiftMentionSpans 平移/丢弃 pendingMentionSpans。注释明确"程序赋值不派发 input 事件"(508-509)
高度自适应 ChatInput.vue:916-922 autoResize(): height='auto' → min(scrollHeight, INPUT_MAX_HEIGHT=200)
光标定位 ChatInput.vue:801-811 selectMention 里 setSelectionRange + 同步重算高度,是既有的"程序化插字+回光标"样板
样式 ChatInput.vue:1201-1215 .ai-input { flex:1; font-size:13px; line-height:1.5; font-family:var(--df-font-sans); resize:vertical; height:20px; max-height:200px; overflow-y:auto }。外层 .ai-input-wrap(1177) position:relative + display:flex; flex-wrap:wrap

1.2 既有浮层/提及基础设施(可复用)

件 锚点 复用点
技能浮层 SkillMention.vue:43-62 .ai-skill-popover 绝对定位浮层
@ 浮层 MentionPopover.vue:39-64 同款 .ai-skill-popover,仅加组标题
浮层定位 src/styles/components.css:254 .ai-skill-popover { position:absolute; bottom:calc(100% + 4px); left:0; right:0; max-height:240px; overflow-y:auto; z-index:20 } —— 浮层锚在输入框上方,输入框整体透明底(.ai-input { background:transparent })
已选技能/图片预览 ChatInput.vue:16-19 输入框上方已有 chip/预览条先例(ImageInput),说明"输入框上方加一条反馈带"是既有形态

1.3 发送与渲染链(证明"发送侧零改动")

件 锚点 现状
发送 ChatInput.vue:924-1046 handleSend 取 inputText.trim() 原样透传 store.sendMessage(text, ...),不做任何 markdown 预处理
渲染器 src/composables/useMarkdown.ts:115-132 marked.setOptions({ breaks:true, gfm:true }) —— 有序/无序列表本就渲染为 <ol>/<ul>
列表样式 src/styles/ai-md.css:8-9 .ai-md ul, .ai-md ol { margin:4px 0; padding-left:20px } / .ai-md li { margin:2px 0; line-height:1.5 }
文本预处理先例 useMarkdown.ts:239-299 wrapNakedDiff marked.parse 前做文本预处理的既有范式;其守卫1(纯 - 列表不误包成 diff)说明已考虑"- 是普通 markdown 列表"的语义

结论: 消息落地后列表渲染链完整,本需求是纯输入侧体验缺口——输入阶段无任何"这是列表"的反馈、无回车续写、无自动补号。

1.4 缺口清单

  1. 输入 1. / - / * / + 后无任何视觉反馈(纯文本),与飞书"行首即成型"的手感差距明显。
  2. 回车续写不补号:按 Shift+Enter 换行后,下一行不会自动带 2. / - 。
  3. 空列表项无退出:清空某行内容后标记 1. 残留,需手工删。
  4. 更深度的是"自动重排号"(中间插一项后续号重排)完全缺失。

二、目标与验收口径

目标 验收口径(可实测)
G1 触发识别 行首匹配 ^(≤3 空格)(\d+\. ) / ^(≤3 空格)([-*+] ) 即判定为列表项;``` 围栏内不触发
G2 回车续写补号 在非空列表项行末按 Shift+Enter → 新行自动前缀正确序号(n → n+1)或同型符号(- →- ),光标落新标记后
G3 空项退出 空列表项行(仅标记+空格)按 Shift+Enter 或 末位退格 → 标记被移除,退出列表态,不产生新项
G4 视觉反馈 列表行标记(1. / - )在输入框内获得区别于正文的样式(高亮/等宽/色块);无法在纯 textarea 内着色时,以"输入框上方列表预览条"替代(见 §3.3)
G5 不打架 / 技能联想、@ 提及、编辑态、IME 组词、代码围栏优先级正确;开启本能力后既有行为零回归
G6 发送不变 发送文本仍为含字面标记的原文,消息气泡经 marked 渲染为列表——发送链/后端/落库零改动

三、设计方案

3.1 总体判断(先说结论)

纯 <textarea> 无法在"框内"渲染真正的列表项(圆点/编号缩进)——textarea 只能显示等宽字符流,无法对子串应用 bullet + hanging indent。因此"框内所见即所得"只有两条路:

档 实现 能力 成本/风险
档一 · 轻量 保留 textarea + 逻辑层(识别/补号/退出)+ 可选 mirror 高亮 补号、退出、标记高亮(字面仍为 1.,但高亮显示) 低-中;mirror 需 textarea 文字透明化,有选区可见性坑
档二 · 完整 contenteditable 富文本编辑器(真 <ol>/<ul>) 真 WYSIWYG、可嵌套/拖动 高;替换 textarea,击穿 IME/@span/粘贴图/autoResize 全部既有机制

建议先做档一,档二单独立项(见 §6 拍板点 2)。理由:档一达成飞书体验的 ~90%(续写补号是主力价值),且完全不碰已验证多年的输入链;档二是输入层重写,风险与工作量不成比例。

3.2 逻辑层设计(档一核心,必做)

抽 src/composables/ai/useListContinuation.ts(纯函数,可单测),不改 textarea 渲染。

数据结构(仅内存,不落库):

export interface ListContext {
  kind: 'ordered' | 'unordered'
  indent: string          // 行首缩进(≤3 空格,保留)
  marker: string          // 字面标记,如 "3. " / "- "
  number?: number         // ordered 的序号(含 . 或 ) 后解析)
  bullet?: '-' | '*' | '+'// unordered 的符号字符
  content: string         // 标记后的正文(可空)
  lineStart: number       // 该行在 inputText 中的起始偏移
  markerEnd: number       // 标记结束偏移(标记后第一个正文字符)
}

/** 判定给定文本 + 光标所处行的列表语境;围栏内/无匹配返回 null */
export function analyzeListContext(text: string, caret: number): ListContext | null

/** 续写:返回应插入的标记串(ordered → `${n+1}. `;unordered → 同型) */
export function nextMarker(ctx: ListContext): string

/** (可选增强)重排:给定文本,把连续 ordered 段重编为 1,2,3…;返回新文本 + 需平移偏移 */
export function renumberOrdered(text: string): { text: string; renumbered: boolean }

触发判据(实现细节):

  • 有序: /^(\s{0,3})(\d{1,9})[.)]\s(.*)$/(兼容 . 与 );markdown 允许 1-9 数字 + ./))
  • 无序: /^(\s{0,3})([-*+])\s(.*)$/
  • 围栏守卫: 从文本第 0 行扫到当前行前,统计 ^\s{0,3}(```|~~~) 出现次数,奇数 → 处于代码围栏内 → 返回 null(不在围栏内把 - 1. 之类误判为列表)。复用 useMarkdown.wrapNakedDiff 同款 _FENCE_OPEN_RE 思路。
  • 光标必须落在当前行(用 \n 计数定位行号),避免"光标在别行却对当前行操作"。

续写流程(Shift+Enter): 在 handleKeydown 里新增分支(位置见 §3.4):

Shift+Enter 且非 skillOpen/mentionOpen/编辑态:
  ctx = analyzeListContext(inputText, caret)
  if !ctx: return(交默认换行)
  if ctx.content.trim() === '':
    // 空项 → 退出列表:删除整行标记,留下空行
    preventDefault; 删除 [lineStart, markerEnd) 区间;
    shiftMentionSpans(旧, 新); 回光标; return
  preventDefault
  insert = '\n' + ctx.indent + nextMarker(ctx)
  在 caret 处插入 insert; 光标置插入末
  shiftMentionSpans(旧, 新)   // 复用 509-532,列表插字同样会平移其后的 @span
  autoResize(); updateMirror()

注意: 程序化赋值不触发 input 事件(ChatInput.vue:508-509 已注明),故 shiftMentionSpans 必须显式手动调用(不能依赖 onInputText),与 selectMention 的处理保持一致。

退格退出(B3): 在 handleKeydown 增 Backspace 分支:光标在行末且当前行为空列表项(^(\s*)([-*+]|\d+[.)])\s$)→ preventDefault,删标记,退出列表。

自动重排号(G-增强,可选): 默认不做。true "自动编号" 需在中间插入/删除项后重写后续行号(1. 2. 3. → 插一行后 1. 2. 3. 4.),会改动用户已输入文本 → 触碰光标/@span 偏移。若做,须走完整"旧文差分 → 重排 → 重新定位光标与 span",成本与回归面显著上升,建议单独开关(见 §6 拍板点 3)。

3.3 视觉反馈设计(三选一,见拍板点 4)

形态 实现 优 劣
V0 零视觉 只做补号/退出 零渲染风险,一文件 不满足"而非纯文本"的观感诉求
V1 mirror 高亮(推荐目标) 在 textarea 下垫一层等宽 mirror div,渲染原文(转义),把列表标记包成 <span class="ai-list-marker"> 高亮;textarea 文字设 color:transparent; caret-color:var(--df-text),可见字符来自 mirror;同步 scrollTop/高度 字面仍为 1. 但框内可见高亮 + 悬挂缩进,最接近飞书 需精确对齐 font/line-height(13px/1.5)、尾行高度、resize:vertical、滚动;选区可见性坑:透明文字下 ::selection 只剩底色、字形不可见,需 textarea::selection{ background:… } 兜且体验打折
V2 预览条 输入框上方(ImageInput 同位置范式)一条折叠/淡显预览,复用 useMarkdown.renderMd(inputText)(已 DOMPurify 净化)渲染当前输入 复用既有渲染单例,零新解析器;能看到真实序号/圆点 非"框内",占垂直空间,与飞书形态有差

建议: 先 V0 落地 G1-G3/G5/G6(最小可用,零渲染风险),再把 V1 作为独立增强步单独评估(透明化对选区/无障碍的影响需实测);V2 作为可选辅助保留。不建议一步到位 V1,因其 textarea 透明化是全局输入体验改动,面比"列表"本身大。

若采用 V1,样式落 src/styles/components.css(与 .ai-skill-popover 同处,因 scoped 不渗透):

.ai-input-field { position: relative; flex: 1; display: flex; }   /* 新包裹层,仅包 textarea */
.ai-input-mirror {
  position: absolute; inset: 0; overflow: hidden; pointer-events: none;
  font: inherit; font-size: 13px; line-height: 1.5; font-family: var(--df-font-sans);
  white-space: pre-wrap; word-break: break-word; color: var(--df-text);
  padding: 0; /* 与 textarea 完全一致,靠 .ai-input-wrap 的 6px 8px 内边距 */
}
.ai-list-marker { color: var(--df-accent); font-weight: 600; }

mirror 文本必须用 document.createTextNode 逐段拼接(禁用 v-html 直插原始输入,防 XSS);仅对已确定的标记区间包 span。若日后想直接渲染整段 markdown 预览,只能走 V2 的 renderMd(已 _purify.sanitize,189-208)。

3.4 与既有机制的优先级(冲突落地)

新增分支在 handleKeydown(814-910)的精确插入点:

IME 守卫(817) → Ctrl+R(822) → 编辑态 Esc(829) → 技能 Esc/chip(836-848)
→ 技能导航(850-872)/技能无匹配 Enter(875)
→ mention 导航(882-905)
→ 【新增】Backspace 空项退出 · 【新增】Shift+Enter 列表续写   ← 放在此处
→ Enter 发送(906-909)
  • / 技能: skillOpen 仅当"整串以 / 开头"(408/423),列表标记在行首,首行不可能是 /;续行也不影响 startsWith 判定 → 不撞。守卫:续写分支前置 !skillOpen。
  • @ 提及: 续写时若光标处 popover 未开,insert 后调 shiftMentionSpans 平移其后的 span;若 popover 开着(mentionOpen),用户意图是选实体,续写分支前置 !mentionOpen。
  • 代码围栏 ```: 由 analyzeListContext 的围栏守卫直接排除(§3.2),- 1. 在围栏内不触发,与 wrapNakedDiff 守卫3一致。
  • IME/isComposing(817): 续写分支位于其后,组词中的 Shift+Enter 不误触。
  • Enter 发送不变: 本设计不改 Enter 发送语义——续写绑定 Shift+Enter(换行链),Enter 仍发送。为最小改动。若要与飞书"Enter 续行"完全一致需改语义,见拍板点 1。

3.5 UI 形态与文案

  • 无新浮层;V1 高亮走 mirror,V2 预览条若采用则置 .ai-input-wrap 之后、EnrichmentPanel(ChatInput.vue:113-121)之前。
  • 提示语: 可选在 .ai-input-hint(122,aiChat.inputHint zh:159)追加"列表续行 Shift+Enter",或单独加一行淡提示。新增 i18n key 落 src/i18n/zh-CN/aiChat.ts + src/i18n/en/aiChat.ts(现有 key 区见 130-165)。

四、分期与最小可用

期 范围 交付 依赖
P1(最小可用) 逻辑层 + V0 useListContinuation.ts(analyze/nextMarker)+ ChatInput 接线(Shift+Enter 续写、Backspace 空项退出、围栏守卫)+ 开关 单测覆盖判定/续写/围栏
P2(视觉) V1 mirror 高亮(或 V2 预览条) 框内标记高亮 + 悬挂缩进;样式入 components.css P1 稳定 + 选区/无障碍实测通过
P3(增强,可选) 自动重排号(renumber) 中间插/删项后重编序号;独立开关 P1/P2 无回归

最小可用定义: P1 完成即视为需求达标可发布——触发识别 + 续写补号 + 空项退出三件事齐,发送侧零改动。视觉(V1/V2)与重排(P3)为增量。


五、影响面(新能力引入评估纪律 · 五维)

维度 判定
兼容性 纯前端输入侧行为,无协议/落库/迁移。既有草稿(convDrafts)、编辑态回填、队列回填文本语义不变(仍是含字面标记的 markdown 原文)。旧会话/历史消息不受影响(渲染链本就支持列表)。PASS
破坏性 增分支只拦 Shift+Enter/Backspace 的特定空项/列表场景,其余键行为不变;Enter 发送语义不变(拍板点 1 若选改才涉及行为变更,且可逆)。无不可逆操作。PASS(低)
安全面 无新端点/命令执行/路径。关键: mirror/预览渲染用户输入时必须转义——mirror 用 textNode 逐段拼接;若用 v-html 走 renderMd(已 DOMPurify.sanitize,useMarkdown.ts:201)。禁裸 v-html=原始输入。新增攻击面为零,仅为既有 XSS 防护纪律的又一次适用。PASS
资源面 每次键入一次纯字符串扫描(O(行数),典型 <100 行)一次可选 mirror DOM 更新。无需 rAF/防抖(P3 重排号若开,是 O(n) 文本改写,仍可控)。无磁盘/日志/内存增量。PASS
回退开关 设 appSettings 键(沿用现网 df-ai-streaming-md / df-ai-auto-execute-mode 范式,useAppSettingsStore().get<boolean>('df-ai-list-input', false)),默认关再灰度;单点置 false 即完全回退到现状 textarea 行为,零残留。PASS(带开关)

六、拍板点(需人定,附建议)

  1. Enter 语义: 保留"Enter=发送,Shift+Enter=换行并续列表"(建议,零回归、不违 aiChat.inputHint 与 handleKeydown:906 既有契约) vs 改为"列表态下 Enter 续行、Cmd/Ctrl+Enter 才发送"(更贴飞书,但变更已固化的发送习惯,置信成本高)。
  2. 渲染档位: 先做档一 轻量、档二 WYSIWYG 单独立项(建议) vs 直接上档二 contenteditable 富文本。档二会击穿 IME/@span/粘贴图/autoResize 全链,工作量与回归面远超本需求。
  3. 范围边界: 标记仅 \d+. + - (建议起步) vs 含 )、*、+、嵌套缩进、以及是否做自动重排号(renumber 改用户文本,单独开关)。
  4. 视觉形态: V0 零视觉先行 → V1 mirror 框内高亮(建议路径,但需实测 textarea 透明化的选区/无障碍影响) vs 直接 V1 vs 用 V2 输入框上方预览条替代。(此项与拍板点 2 联动:若否决档二,则 V1 是"框内感"的唯一可达形态。)

附:实施落点清单(供实施会话直接取用)

文件 改动
src/composables/ai/useListContinuation.ts 新增:analyzeListContext / nextMarker / 围栏守卫 /(可选)renumberOrdered + 单测
src/components/ai/ChatInput.vue handleKeydown(814-910)插入 Shift+Enter/Backspace 分支;<script> 引入 composable;V1 时包一层 .ai-input-field + <div class="ai-input-mirror"> 并同步 scroll/height;读开关 df-ai-list-input
src/styles/components.css V1:.ai-input-field / .ai-input-mirror / .ai-list-marker
src/i18n/zh-CN/aiChat.ts + src/i18n/en/aiChat.ts 可选:续行提示文案

参照档: 无人值守长任务-设计-2026-10-04.md(结构/详略) · 上下文三层记忆-设计-2026-10-04.md · 跨会话召回最小闭环-设计-2026-10-04.md(同一目录写法)。