- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」 - 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content - 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类 - 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
178 lines
5.2 KiB
Vue
178 lines
5.2 KiB
Vue
<template>
|
||
<section v-if="rows.length" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
|
||
<header class="qd-window__header">
|
||
<svg class="qd-window__icon" viewBox="0 0 16 16" fill="none">
|
||
<path
|
||
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
|
||
stroke="currentColor"
|
||
stroke-width="1.3"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
|
||
</svg>
|
||
<span class="qd-window__title">文件</span>
|
||
<span class="qd-window__counter">{{ rows.length }}</span>
|
||
</header>
|
||
<ul ref="listRef" class="qd-list">
|
||
<li
|
||
v-for="row in rows"
|
||
:key="row.path"
|
||
class="qd-file-item"
|
||
:class="{ 'is-active': previewPath === row.path, 'qd-row-enter': row.entering }"
|
||
:style="row.pendingEnter ? { opacity: '0' } : undefined"
|
||
:title="row.path"
|
||
@click="openRow(row)"
|
||
>
|
||
<button class="qd-row-menu-btn" title="更多" @click.stop="openMenu($event, row)">
|
||
<svg viewBox="0 0 16 16">
|
||
<circle cx="3.5" cy="8" r="1.3" fill="currentColor" />
|
||
<circle cx="8" cy="8" r="1.3" fill="currentColor" />
|
||
<circle cx="12.5" cy="8" r="1.3" fill="currentColor" />
|
||
</svg>
|
||
</button>
|
||
<span class="qd-file-name">{{ basename(row.path) }}</span>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { nextTick, ref, watch } from 'vue';
|
||
import { storeToRefs } from 'pinia';
|
||
import { useQuickDockStore } from '@/stores/quickDock';
|
||
|
||
/**
|
||
* 文件记录窗口
|
||
* 本次对话中编辑/创建过且仍存在的文件;同一 path 只出现一次。
|
||
* 行结构:[⋯] 文件名;点击行 → 右侧预览侧边栏;⋯ → 菜单(下载/打开/复制路径)
|
||
*/
|
||
|
||
interface Row {
|
||
path: string;
|
||
entering: boolean;
|
||
pendingEnter: boolean;
|
||
}
|
||
|
||
const quickDock = useQuickDockStore();
|
||
const { editedFiles, previewPath } = storeToRefs(quickDock);
|
||
|
||
const rows = ref<Row[]>([]);
|
||
const windowEntering = ref(false);
|
||
const listRef = ref<HTMLElement | null>(null);
|
||
let windowEnterShown = false;
|
||
|
||
function basename(p: string): string {
|
||
return p.split('/').pop() || p;
|
||
}
|
||
|
||
function openRow(row: Row) {
|
||
quickDock.closeMenu();
|
||
quickDock.openPreview(row.path);
|
||
}
|
||
|
||
function openMenu(e: MouseEvent, row: Row) {
|
||
const btn = e.currentTarget as HTMLElement;
|
||
const rect = btn.getBoundingClientRect();
|
||
quickDock.openMenu({
|
||
type: 'file',
|
||
key: row.path,
|
||
left: rect.left, // 与按钮左对齐、向下展开
|
||
top: rect.bottom + 6,
|
||
alignRight: false
|
||
});
|
||
}
|
||
|
||
function scrollElIntoView(el: HTMLElement): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
const listEl = listRef.value;
|
||
if (!listEl) {
|
||
resolve();
|
||
return;
|
||
}
|
||
const listRect = listEl.getBoundingClientRect();
|
||
const elRect = el.getBoundingClientRect();
|
||
const fullyVisible = elRect.top >= listRect.top && elRect.bottom <= listRect.bottom;
|
||
if (fullyVisible) {
|
||
resolve();
|
||
return;
|
||
}
|
||
let done = false;
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
const finish = () => {
|
||
if (done) {
|
||
return;
|
||
}
|
||
done = true;
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
listEl.removeEventListener('scroll', onScroll);
|
||
resolve();
|
||
};
|
||
const onScroll = () => {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
timer = setTimeout(finish, 90);
|
||
};
|
||
listEl.addEventListener('scroll', onScroll);
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
timer = setTimeout(finish, 800);
|
||
});
|
||
}
|
||
|
||
async function playEnter(row: Row) {
|
||
await nextTick();
|
||
const listEl = listRef.value;
|
||
const index = rows.value.indexOf(row);
|
||
const el = listEl?.children[index] as HTMLElement | undefined;
|
||
if (!el) {
|
||
row.pendingEnter = false;
|
||
row.entering = true;
|
||
setTimeout(() => {
|
||
row.entering = false;
|
||
}, 380);
|
||
return;
|
||
}
|
||
await scrollElIntoView(el);
|
||
row.pendingEnter = false;
|
||
row.entering = true;
|
||
setTimeout(() => {
|
||
row.entering = false;
|
||
}, 380);
|
||
}
|
||
|
||
watch(
|
||
editedFiles,
|
||
(list) => {
|
||
// live=true 来自任务期实时事件(播动画);false 来自加载/bootstrap(静态呈现)
|
||
const live = quickDock.editedFilesLive;
|
||
const byPath = new Map(list.map((item) => [item.path, item]));
|
||
|
||
// 移除已消失的行(文件被删除 / 对话切换清空)
|
||
rows.value = rows.value.filter((row) => byPath.has(row.path));
|
||
|
||
// 新增行:实时事件隐身插入 → 滚动露出 → 播进入动画;加载来源直接静态插入
|
||
const existing = new Set(rows.value.map((row) => row.path));
|
||
const added = list.filter((item) => !existing.has(item.path));
|
||
if (added.length) {
|
||
if (live && !windowEnterShown && !rows.value.length) {
|
||
windowEnterShown = true;
|
||
windowEntering.value = true;
|
||
setTimeout(() => {
|
||
windowEntering.value = false;
|
||
}, 320);
|
||
}
|
||
added.forEach((item) => {
|
||
const row: Row = { path: item.path, entering: false, pendingEnter: live };
|
||
rows.value.push(row);
|
||
if (live) {
|
||
void playEnter(row);
|
||
}
|
||
});
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
</script>
|