fix(frontend): restore conversation delete animation

Use a two-phase delete flow for conversation list items.

Instead of immediately removing the deleted conversation from the array and relying only on transition-group leave hooks, mark the target conversation as pending deletion first so it can play the left-slide animation, then remove it after the animation completes.

Also keep the list container mounted during delete mode so empty/loading branches do not interrupt the exit transition.

Validation: npm run build --silent 2>&1 | tail -n 20
This commit is contained in:
JOJO 2026-05-29 19:57:23 +08:00
parent 08d9812f45
commit b1cf4b9ff2
5 changed files with 40 additions and 5 deletions

View File

@ -58,6 +58,7 @@ export const computed = {
'searchResults',
'conversationInsertAnimations',
'conversationListAnimationMode',
'pendingDeletingConversationIds',
'searchActive',
'searchInProgress',
'searchMoreAvailable',

View File

@ -631,17 +631,31 @@ export const conversationMethods = {
history.replaceState({}, '', '/new');
}
// 先从本地列表移除,让侧边栏执行删除动画;随后再刷新服务端列表补齐分页状态
// 先给目标项加 deleting 类执行左滑动画,再从本地列表移除。
// 这比完全依赖 transition-group leave 更稳定:即使列表即将为空或分支切换,也能先播放离场动画。
this.conversationListAnimationMode = 'delete';
const deletingIds = Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds
: [];
if (!deletingIds.includes(conversationId)) {
this.pendingDeletingConversationIds = [...deletingIds, conversationId];
}
await this.$nextTick();
await waitForAnimation(430);
this.conversations = this.conversations.filter(
(conversation) => conversation.id !== conversationId
);
this.searchResults = this.searchResults.filter(
(conversation) => conversation.id !== conversationId
);
this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds
: []
).filter((id) => id !== conversationId);
// 等待“左滑离场 + 0.1s 后集体上移”动画结束,避免刷新列表打断过渡。
await waitForAnimation(560);
await waitForAnimation(180);
// 刷新对话列表
this.conversationsOffset = 0;
@ -656,6 +670,11 @@ export const conversationMethods = {
});
}
} catch (error) {
this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds
: []
).filter((id) => id !== conversationId);
this.conversationListAnimationMode = 'idle';
console.error('删除对话异常:', error);
this.uiPushToast({
title: '删除对话异常',

View File

@ -129,12 +129,12 @@
</button>
</div>
<div
v-if="loading && !displayConversations.length && !searchActive"
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading" class="no-conversations">
<div v-else-if="!displayConversations.length && !loading && !isDeletingConversation" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
<transition-group
@ -153,7 +153,8 @@
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource'
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource',
deleting: pendingDeletingConversationIds.includes(conv.id)
}"
@click="
openActionMenuId = null;
@ -338,6 +339,7 @@ const {
conversations,
conversationInsertAnimations,
conversationListAnimationMode,
pendingDeletingConversationIds,
conversationsLoading,
hasMoreConversations: hasMore,
loadingMoreConversations: loadingMore,
@ -375,6 +377,7 @@ const displayConversations = computed(() =>
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
const isDeletingConversation = computed(() => conversationListAnimationMode.value === 'delete');
const runningTaskItems = computed(() =>
Array.isArray(props.runningTasks)
? props.runningTasks.filter(

View File

@ -13,6 +13,7 @@ interface ConversationState {
searchResults: ConversationSummary[];
conversationInsertAnimations: Record<string, 'create' | 'duplicate' | 'duplicateSource'>;
conversationListAnimationMode: 'idle' | 'create' | 'duplicate' | 'delete';
pendingDeletingConversationIds: string[];
conversationsLoading: boolean;
hasMoreConversations: boolean;
loadingMoreConversations: boolean;
@ -37,6 +38,7 @@ export const useConversationStore = defineStore('conversation', {
searchResults: [],
conversationInsertAnimations: {},
conversationListAnimationMode: 'idle',
pendingDeletingConversationIds: [],
conversationsLoading: false,
hasMoreConversations: false,
loadingMoreConversations: false,
@ -60,6 +62,7 @@ export const useConversationStore = defineStore('conversation', {
this.searchResults = [];
this.conversationInsertAnimations = {};
this.conversationListAnimationMode = 'idle';
this.pendingDeletingConversationIds = [];
this.searchActive = false;
this.searchInProgress = false;
this.searchMoreAvailable = false;

View File

@ -394,6 +394,15 @@ body[data-theme='dark']
opacity: 0;
}
.conversation-sidebar .conversation-item.deleting {
transform: translateX(-112%);
opacity: 0;
pointer-events: none;
transition:
transform 420ms cubic-bezier(0.32, 0, 0.67, 0),
opacity 260ms ease;
}
.conversation-sidebar .conversation-item:hover,
.conversation-sidebar .conversation-item:focus-within,
.conversation-sidebar .conversation-item.active:hover,