|
|
|
|
@@ -41,6 +41,7 @@
|
|
|
|
|
:class="{
|
|
|
|
|
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
|
|
|
|
'ai-conv-item--archived': conv.archived,
|
|
|
|
|
'ai-conv-item--pinned': conv.pinned,
|
|
|
|
|
}"
|
|
|
|
|
@click="onSelectSearchResult(conv.id)"
|
|
|
|
|
>
|
|
|
|
|
@@ -52,25 +53,25 @@
|
|
|
|
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ai-conv-item-actions">
|
|
|
|
|
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, !conv.archived)" :title="conv.archived ? t('aiChat.unarchive') : t('aiChat.archive')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="ai-conv-item-export">
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
|
|
|
<div class="ai-conv-item-more">
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
|
|
|
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
|
|
|
|
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
|
|
|
{{ t('common.delete') }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="emit('confirm-delete', conv.id)" :title="t('common.delete')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
|
|
|
|
@@ -100,7 +101,7 @@
|
|
|
|
|
v-show="!store.state.foldedGroups[group.key]"
|
|
|
|
|
:key="conv.id"
|
|
|
|
|
class="ai-conv-item"
|
|
|
|
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
|
|
|
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
|
|
|
|
@click="store.switchConversation(conv.id)"
|
|
|
|
|
>
|
|
|
|
|
<div class="ai-conv-item-main">
|
|
|
|
|
@@ -134,25 +135,25 @@
|
|
|
|
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ai-conv-item-actions">
|
|
|
|
|
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, true)" :title="t('aiChat.archive')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="ai-conv-item-export">
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
|
|
|
<div class="ai-conv-item-more">
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
|
|
|
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
|
|
|
|
{{ t('aiChat.archive') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
|
|
|
{{ t('common.delete') }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="emit('confirm-delete', conv.id)" :title="t('common.delete')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
@@ -168,7 +169,7 @@
|
|
|
|
|
v-show="!store.state.archivedCollapsed"
|
|
|
|
|
:key="'a-' + conv.id"
|
|
|
|
|
class="ai-conv-item ai-conv-item--archived"
|
|
|
|
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
|
|
|
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
|
|
|
|
@click="store.switchConversation(conv.id)"
|
|
|
|
|
>
|
|
|
|
|
<div class="ai-conv-item-main">
|
|
|
|
|
@@ -179,25 +180,25 @@
|
|
|
|
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ai-conv-item-actions">
|
|
|
|
|
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, false)" :title="t('aiChat.unarchive')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="ai-conv-item-export">
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
|
|
|
<div class="ai-conv-item-more">
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
|
|
|
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
|
|
|
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
|
|
|
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
|
|
|
|
|
{{ t('aiChat.unarchive') }}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
|
|
|
{{ t('common.delete') }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="ai-conv-item-act" @click.stop="emit('confirm-delete', conv.id)" :title="t('common.delete')">
|
|
|
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
@@ -223,17 +224,15 @@
|
|
|
|
|
* confirmDialog 实例归父所有(useConfirm 每次调用返回独立 ref,非全局单例),
|
|
|
|
|
* 故删除确认交父处理。父调原 confirmDeleteConversation(id)。
|
|
|
|
|
* - `confirm-new-conversation`:新建对话(生成中走确认弹层),交父 confirmNewConversation。
|
|
|
|
|
* - `export-error`(msg):导出失败 toast,父 showToast(分离窗口自管 toast)。
|
|
|
|
|
* - 父 Ctrl+K 全局快捷键聚焦搜索框:经 defineExpose({ focusSearch }) 供父调,
|
|
|
|
|
* 父侧 ref 替代原 searchInputRef(零行为变更)。
|
|
|
|
|
*
|
|
|
|
|
* 本组件自管的纯侧栏态:titleFlash/重命名/导出菜单/侧栏拖拽/搜索结果/时间分组。
|
|
|
|
|
* 本组件自管的纯侧栏态:titleFlash/重命名/更多操作菜单/侧栏拖拽/搜索结果/时间分组。
|
|
|
|
|
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
|
|
|
|
*/
|
|
|
|
|
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
import { useAiStore } from '../../stores/ai'
|
|
|
|
|
import { aiApi } from '../../api'
|
|
|
|
|
import { formatRelativeZh } from '../../utils/time'
|
|
|
|
|
import type { AiConversationSummary } from '../../api/types'
|
|
|
|
|
|
|
|
|
|
@@ -242,8 +241,6 @@ const emit = defineEmits<{
|
|
|
|
|
(e: 'confirm-delete', id: string): void
|
|
|
|
|
/** 新建对话(生成中需二次确认),父调 confirmNewConversation() */
|
|
|
|
|
(e: 'confirm-new-conversation'): void
|
|
|
|
|
/** 导出失败,父 showToast(msg, 'error') */
|
|
|
|
|
(e: 'export-error', msg: string): void
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
|
@@ -304,57 +301,23 @@ function cancelRename() {
|
|
|
|
|
editingConvId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── UX-18: 对话导出 ──
|
|
|
|
|
const exportOpenId = ref<string | null>(null)
|
|
|
|
|
const exportingId = ref<string | null>(null)
|
|
|
|
|
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
|
|
|
|
const actionsOpenId = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
|
function toggleExportMenu(id: string) {
|
|
|
|
|
exportOpenId.value = exportOpenId.value === id ? null : id
|
|
|
|
|
function toggleActionsMenu(id: string) {
|
|
|
|
|
actionsOpenId.value = actionsOpenId.value === id ? null : id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitizeFilename(title: string | null | undefined, fallback: string): string {
|
|
|
|
|
const raw = (title || '').trim()
|
|
|
|
|
if (!raw) return fallback
|
|
|
|
|
const cleaned = raw.replace(/[\\/:*?"<>|\r\n\t]+/g, '_').replace(/\s+/g, '_')
|
|
|
|
|
return (cleaned.replace(/^_+|_+$/g, '').slice(0, 60)) || fallback
|
|
|
|
|
function closeActionsMenu() {
|
|
|
|
|
actionsOpenId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportConversation(conv: AiConversationSummary, format: 'markdown' | 'json' | 'txt') {
|
|
|
|
|
exportOpenId.value = null
|
|
|
|
|
if (exportingId.value) return
|
|
|
|
|
exportingId.value = conv.id
|
|
|
|
|
try {
|
|
|
|
|
const content = await aiApi.exportConversation(conv.id, format)
|
|
|
|
|
const ext = format === 'markdown' ? 'md' : format === 'json' ? 'json' : 'txt'
|
|
|
|
|
const mime = format === 'markdown' ? 'text/markdown;charset=utf-8'
|
|
|
|
|
: format === 'json' ? 'application/json;charset=utf-8'
|
|
|
|
|
: 'text/plain;charset=utf-8'
|
|
|
|
|
const base = sanitizeFilename(conv.title, conv.id)
|
|
|
|
|
const filename = `conversation-${base}.${ext}`
|
|
|
|
|
const blob = new Blob([content], { type: mime })
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
const a = document.createElement('a')
|
|
|
|
|
a.href = url
|
|
|
|
|
a.download = filename
|
|
|
|
|
document.body.appendChild(a)
|
|
|
|
|
a.click()
|
|
|
|
|
document.body.removeChild(a)
|
|
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
const msg = e?.message || String(e)
|
|
|
|
|
console.error('[AI] 导出对话失败:', e)
|
|
|
|
|
emit('export-error', t('aiChat.exportFailed', { msg }))
|
|
|
|
|
} finally {
|
|
|
|
|
exportingId.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 点击外部关闭导出菜单
|
|
|
|
|
function onExportOutsideClick(e: MouseEvent) {
|
|
|
|
|
if (!exportOpenId.value) return
|
|
|
|
|
// 点击外部关闭更多操作菜单
|
|
|
|
|
function onActionsOutsideClick(e: MouseEvent) {
|
|
|
|
|
if (!actionsOpenId.value) return
|
|
|
|
|
const target = e.target as HTMLElement | null
|
|
|
|
|
if (target?.closest('.ai-conv-item-export')) return
|
|
|
|
|
exportOpenId.value = null
|
|
|
|
|
if (target?.closest('.ai-conv-item-more')) return
|
|
|
|
|
actionsOpenId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -444,12 +407,12 @@ function onResizeStart(e: MouseEvent) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
// UX-18: 点击外部关闭导出格式菜单(原 AiChat onMounted 同段迁移)
|
|
|
|
|
document.addEventListener('click', onExportOutsideClick)
|
|
|
|
|
// 点击外部关闭更多操作菜单
|
|
|
|
|
document.addEventListener('click', onActionsOutsideClick)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
document.removeEventListener('click', onExportOutsideClick)
|
|
|
|
|
document.removeEventListener('click', onActionsOutsideClick)
|
|
|
|
|
// UX-2025-16:拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
|
|
|
|
document.removeEventListener('mousemove', onResizeMove)
|
|
|
|
|
document.removeEventListener('mouseup', onResizeEnd)
|
|
|
|
|
@@ -684,6 +647,10 @@ body.ai-sidebar-resizing * {
|
|
|
|
|
.ai-conv-item:hover .ai-conv-item-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
}
|
|
|
|
|
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
|
|
|
|
|
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-item-act {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
@@ -701,36 +668,27 @@ body.ai-sidebar-resizing * {
|
|
|
|
|
background: var(--df-sidebar-hover);
|
|
|
|
|
color: var(--df-text);
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-item-act:last-child:hover {
|
|
|
|
|
background: var(--df-danger-bg);
|
|
|
|
|
color: var(--df-danger);
|
|
|
|
|
}
|
|
|
|
|
/* UX-17: 置顶按钮高亮态(已置顶时常驻强调色,不依赖 hover) */
|
|
|
|
|
.ai-conv-item-act--pinned {
|
|
|
|
|
color: var(--df-accent, var(--df-text));
|
|
|
|
|
}
|
|
|
|
|
/* CR-22-1: 已置顶对话视觉强调(左边线,不依赖 hover 图钉一眼识别)。
|
|
|
|
|
原方案「图钉常驻」与 hover 浮出 actions 矛盾(父 display:none 压制子),
|
|
|
|
|
改用 :has 给已置顶 item 左 accent 边线,hover 浮出操作不变。 */
|
|
|
|
|
.ai-conv-item:has(.ai-conv-item-act--pinned) {
|
|
|
|
|
/* CR-22-1: 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
|
|
|
|
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */
|
|
|
|
|
.ai-conv-item--pinned {
|
|
|
|
|
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
|
|
|
|
|
}
|
|
|
|
|
/* ── UX-18: 对话导出按钮 + 格式菜单 ──
|
|
|
|
|
导出按钮位于归档与删除之间(非 last-child,保持删除按钮 :last-child 红色态不被破坏)。
|
|
|
|
|
菜单绝对定位浮于 item 下方右侧,窄侧栏优先避免溢出。 */
|
|
|
|
|
.ai-conv-item-export {
|
|
|
|
|
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
|
|
|
|
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
|
|
|
|
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
|
|
|
|
|
.ai-conv-item-more {
|
|
|
|
|
position: relative;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-export-menu {
|
|
|
|
|
.ai-conv-actions-menu {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 100%;
|
|
|
|
|
right: 0;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
z-index: 20;
|
|
|
|
|
min-width: 84px;
|
|
|
|
|
min-width: 104px;
|
|
|
|
|
padding: 2px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
@@ -740,20 +698,27 @@ body.ai-sidebar-resizing * {
|
|
|
|
|
border-radius: var(--df-radius-sm);
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-export-opt {
|
|
|
|
|
.ai-conv-actions-opt {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
border: none;
|
|
|
|
|
background: transparent;
|
|
|
|
|
text-align: left;
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
padding: 5px 8px;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--df-text);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border-radius: var(--df-radius-sm);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-export-opt:hover {
|
|
|
|
|
.ai-conv-actions-opt:hover {
|
|
|
|
|
background: var(--df-sidebar-hover);
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-actions-opt--danger:hover {
|
|
|
|
|
background: var(--df-danger-bg);
|
|
|
|
|
color: var(--df-danger);
|
|
|
|
|
}
|
|
|
|
|
.ai-conv-empty {
|
|
|
|
|
padding: 16px 8px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
|