重构: ChatInput 拆分(提取 SkillMention+ImageInput, 1183->1155行)

- 提取 SkillMention.vue(/ 技能联想浮层+已选 chip)

- 提取 ImageInput.vue(待发送图片预览+移除)

- ChatInput 模板内联技能浮层替换为子组件
This commit is contained in:
2026-07-01 12:27:58 +08:00
parent 2c2b2d710e
commit 3abdfb489f
4 changed files with 122 additions and 45 deletions

View File

@@ -386,16 +386,25 @@
## Batch 31 — God 文件拆分第一步(MessageList) ## Batch 31 — God 文件拆分第一步(MessageList)
- **提交**: `当前待提交` - **提交**: `2c2b2d7`
- **内容**: - **内容**:
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行) - 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行) - 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
- MessageList.vue 从 1552 行降至 1386 行(-166 行) - MessageList.vue 从 1552 行降至 1386 行(-166 行)
- **验证**: vue-tsc + vite build 通过 - **验证**: vue-tsc + vite build 通过
## Batch 32 — God 文件拆分第二步(ChatInput)
- **提交**: `当前待提交`
- **内容**:
- 提取 SkillMention.vue(/@ 联想浮层,63 行)
- 提取 ImageInput.vue(图片粘贴/拖拽预览,33 行)
- ChatInput.vue 从 1183 行降至 1155 行
- **验证**: vue-tsc + vite build 通过
## 后续规划批次(待推进) ## 后续规划批次(待推进)
### Batch 32ChatInput 拆分 ### Batch 33多 ReAct 对接
1. 提取 SkillMention.vue(/@ 联想浮层) 1. 提取 SkillMention.vue(/@ 联想浮层)
2. 提取 ImageInput.vue(图片粘贴/拖拽) 2. 提取 ImageInput.vue(图片粘贴/拖拽)
3. ChatInput.vue 从 1183 行降至 ~500 行 3. ChatInput.vue 从 1183 行降至 ~500 行

View File

@@ -1,28 +1,18 @@
<template> <template>
<div class="ai-input-area"> <div class="ai-input-area">
<!-- 已选技能 chip含参数格式提示 argument_hint --> <SkillMention
<div v-if="pendingSkill" class="ai-skill-chip"> :skill-open="skillOpen"
<span class="ai-skill-chip-main"> :filtered-skills="filteredSkills"
<span class="ai-skill-chip-name">/{{ pendingSkill.name }}</span> :skill-index="skillIndex"
<span v-if="pendingSkill.description" class="ai-skill-chip-desc">{{ pendingSkill.description }}</span> :pending-skill="pendingSkill"
<code v-if="pendingSkill.argument_hint" class="ai-skill-chip-hint">{{ pendingSkill.argument_hint }}</code> :skill-query="skillQuery"
</span> @select="selectSkill"
<button class="ai-skill-chip-x" @click="clearSkill" :title="$t('aiChat.clearSkill')" :aria-label="$t('aiChat.clearSkill')">×</button> @clear="clearSkill"
</div> @update:skill-index="skillIndex = $event"
/>
<div class="ai-input-wrap"> <div class="ai-input-wrap">
<!-- F-260614-05 Phase 2b: 待发送图片预览(粘贴/拖拽加入,发送前可移除) --> <!-- F-260614-05 Phase 2b: 待发送图片预览 -->
<div v-if="pendingImages.length" class="ai-img-preview-row"> <ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
<img :src="img.dataUrl" :alt="$t('aiChat.imagePreviewAlt')" class="ai-img-preview-thumb" />
<button
type="button"
class="ai-img-preview-x"
:title="$t('aiChat.removeImage')"
:aria-label="$t('aiChat.removeImage')"
@click="removePendingImage(img.id)"
>×</button>
</div>
</div>
<textarea <textarea
ref="inputEl" ref="inputEl"
v-model="inputText" v-model="inputText"
@@ -38,27 +28,7 @@
@dragover="onDragOver" @dragover="onDragOver"
@drop="onDrop" @drop="onDrop"
></textarea> ></textarea>
<!-- 技能联想浮层 -->
<div v-if="skillOpen" class="ai-skill-popover">
<div v-if="!filteredSkills.length" class="ai-skill-empty">
{{ store.state.skills.length ? $t('aiChat.skillNoMatch') : $t('aiChat.skillNotLoaded') }}
</div>
<div
v-for="(s, i) in filteredSkills"
:key="s.path"
class="ai-skill-item"
:class="{ 'ai-skill-item--active': i === skillIndex }"
@click="selectSkill(s)"
@mouseenter="skillIndex = i"
>
<div class="ai-skill-item-row">
<span class="ai-skill-item-name">/{{ s.name }}</span>
<span class="ai-skill-item-desc">{{ s.description }}</span>
<span class="ai-skill-item-src">{{ s.source }}</span>
</div>
<code v-if="s.argument_hint" class="ai-skill-item-hint">{{ s.argument_hint }}</code>
</div>
</div>
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:] 标记,架构复用技能 popover) --> <!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:] 标记,架构复用技能 popover) -->
<div v-if="mentionOpen" class="ai-skill-popover ai-mention-popover"> <div v-if="mentionOpen" class="ai-skill-popover ai-mention-popover">
<div v-if="!mentionItems.length" class="ai-skill-empty"> <div v-if="!mentionItems.length" class="ai-skill-empty">
@@ -158,6 +128,8 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project' import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents' import { getConvState } from '../../composables/ai/useAiEvents'
import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types' import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
const props = defineProps<{ const props = defineProps<{

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
//! 图片输入预览 — 待发送图片缩略图列表,从 ChatInput.vue 提取
//!
//! 职责:显示待发送图片缩略图、移除按钮。不含图片读取/粘贴/拖拽逻辑。
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
/** 待发送图片列表 */
pendingImages: { id: string; dataUrl: string; mediaType: string }[]
}>()
const emit = defineEmits<{
(e: 'remove', id: string): void
}>()
</script>
<template>
<div v-if="pendingImages.length" class="ai-img-preview-row">
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
<img :src="img.dataUrl" :alt="t('aiChat.imagePreviewAlt')" class="ai-img-preview-thumb" />
<button
type="button"
class="ai-img-preview-x"
:title="t('aiChat.removeImage')"
:aria-label="t('aiChat.removeImage')"
@click="emit('remove', img.id)"
>&times;</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
//! 技能联想浮层 — / 触发技能选择,从 ChatInput.vue 提取
//!
//! 职责:显示 /@ 联想浮层(Filtered skill list)、选中/清除技能、关联 i18n
import { useI18n } from 'vue-i18n'
import type { SkillInfo } from '../../api/types'
const { t } = useI18n()
defineProps<{
/** 联想浮层是否打开 */
skillOpen: boolean
/** 过滤后的技能列表 */
filteredSkills: SkillInfo[]
/** 当前高亮索引 */
skillIndex: number
/** 已选技能(用于顶部 chip 展示) */
pendingSkill: SkillInfo | null
/** 技能查询文本 */
skillQuery: string
}>()
const emit = defineEmits<{
(e: 'select', skill: SkillInfo): void
(e: 'clear'): void
(e: 'update:skillIndex', index: number): void
}>()
</script>
<template>
<!-- 已选技能 chip -->
<div v-if="pendingSkill" class="ai-skill-chip">
<span class="ai-skill-chip-main">
<span class="ai-skill-chip-name">/{{ pendingSkill.name }}</span>
<span v-if="pendingSkill.description" class="ai-skill-chip-desc">{{ pendingSkill.description }}</span>
<code v-if="pendingSkill.argument_hint" class="ai-skill-chip-hint">{{ pendingSkill.argument_hint }}</code>
</span>
<button class="ai-skill-chip-x" @click="emit('clear')" :title="t('aiChat.clearSkill')" :aria-label="t('aiChat.clearSkill')">&times;</button>
</div>
<!-- 技能联想浮层 -->
<div v-if="skillOpen" class="ai-skill-popover">
<div v-if="!filteredSkills.length" class="ai-skill-empty">
{{ skillQuery.length ? t('aiChat.skillNoMatch') : t('aiChat.skillNotLoaded') }}
</div>
<div
v-for="(s, i) in filteredSkills"
:key="s.path"
class="ai-skill-item"
:class="{ 'ai-skill-item--active': i === skillIndex }"
@click="emit('select', s)"
@mouseenter="emit('update:skillIndex', i)"
>
<div class="ai-skill-item-row">
<span class="ai-skill-item-name">/{{ s.name }}</span>
<span class="ai-skill-item-desc">{{ s.description }}</span>
<span class="ai-skill-item-src">{{ s.source }}</span>
</div>
<code v-if="s.argument_hint" class="ai-skill-item-hint">{{ s.argument_hint }}</code>
</div>
</div>
</template>