feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互 - 提供 Markdown 实时编辑、源码与脚本资源工作台 - 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@easyflow-core/editor-ui": "workspace:*",
|
||||
"@easyflow-core/form-ui": "workspace:*",
|
||||
"@easyflow-core/popup-ui": "workspace:*",
|
||||
"@easyflow-core/preferences": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
inspectorOpen?: boolean;
|
||||
treeLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
inspectorOpen: false,
|
||||
treeLabel: '文件',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeInspector: [];
|
||||
}>();
|
||||
|
||||
const mobileTreeOpen = ref(false);
|
||||
const mobileTriggerRef = ref<HTMLButtonElement>();
|
||||
const mobileCloseRef = ref<HTMLButtonElement>();
|
||||
const inspectorCloseRef = ref<HTMLButtonElement>();
|
||||
let inspectorReturnFocus: HTMLElement | undefined;
|
||||
|
||||
watch(mobileTreeOpen, async (open) => {
|
||||
await nextTick();
|
||||
if (open) mobileCloseRef.value?.focus();
|
||||
else mobileTriggerRef.value?.focus();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.inspectorOpen,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
inspectorReturnFocus = document.activeElement as HTMLElement;
|
||||
await nextTick();
|
||||
inspectorCloseRef.value?.focus();
|
||||
} else {
|
||||
inspectorReturnFocus?.focus();
|
||||
inspectorReturnFocus = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (mobileTreeOpen.value) {
|
||||
mobileTreeOpen.value = false;
|
||||
} else if (props.inspectorOpen) {
|
||||
emit('closeInspector');
|
||||
}
|
||||
});
|
||||
|
||||
function trapMobileTreeFocus(event: KeyboardEvent) {
|
||||
if (!mobileTreeOpen.value || event.key !== 'Tab') return;
|
||||
const panel = mobileCloseRef.value?.closest<HTMLElement>(
|
||||
'.document-editor-workbench__tree',
|
||||
);
|
||||
if (!panel) return;
|
||||
const focusable = [
|
||||
...panel.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
].filter((item) => item.offsetParent !== null);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', trapMobileTreeFocus));
|
||||
onBeforeUnmount(() =>
|
||||
document.removeEventListener('keydown', trapMobileTreeFocus),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="document-editor-workbench">
|
||||
<button
|
||||
ref="mobileTriggerRef"
|
||||
class="document-editor-workbench__mobile-trigger"
|
||||
type="button"
|
||||
@click="mobileTreeOpen = true"
|
||||
>
|
||||
{{ treeLabel }}
|
||||
</button>
|
||||
<aside
|
||||
class="document-editor-workbench__tree"
|
||||
:class="{ 'is-mobile-open': mobileTreeOpen }"
|
||||
:aria-label="treeLabel"
|
||||
:aria-modal="mobileTreeOpen || undefined"
|
||||
:role="mobileTreeOpen ? 'dialog' : 'region'"
|
||||
>
|
||||
<div class="document-editor-workbench__tree-mobile-head">
|
||||
<strong>{{ treeLabel }}</strong>
|
||||
<button
|
||||
ref="mobileCloseRef"
|
||||
type="button"
|
||||
aria-label="关闭文件列表"
|
||||
@click="mobileTreeOpen = false"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<slot name="tree" :close="() => (mobileTreeOpen = false)"></slot>
|
||||
</aside>
|
||||
<button
|
||||
v-if="mobileTreeOpen"
|
||||
class="document-editor-workbench__scrim"
|
||||
type="button"
|
||||
aria-label="关闭文件列表"
|
||||
@click="mobileTreeOpen = false"
|
||||
></button>
|
||||
<main
|
||||
class="document-editor-workbench__main"
|
||||
:aria-hidden="mobileTreeOpen || undefined"
|
||||
:inert="mobileTreeOpen || undefined"
|
||||
>
|
||||
<div v-if="$slots.toolbar" class="document-editor-workbench__toolbar">
|
||||
<slot name="toolbar"></slot>
|
||||
</div>
|
||||
<div class="document-editor-workbench__editor">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<slot name="status"></slot>
|
||||
</main>
|
||||
<Transition name="document-inspector">
|
||||
<aside
|
||||
v-if="inspectorOpen"
|
||||
class="document-editor-workbench__inspector"
|
||||
role="complementary"
|
||||
aria-label="文档检查面板"
|
||||
>
|
||||
<button
|
||||
ref="inspectorCloseRef"
|
||||
class="document-editor-workbench__inspector-close"
|
||||
type="button"
|
||||
aria-label="关闭检查面板"
|
||||
@click="emit('closeInspector')"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<slot name="inspector"></slot>
|
||||
</aside>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.document-editor-workbench {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(248px, 272px) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-right: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.document-editor-workbench__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-editor-workbench__toolbar {
|
||||
z-index: 2;
|
||||
min-height: 48px;
|
||||
background: hsl(var(--toolbar-bg) / 92%);
|
||||
border-bottom: 1px solid hsl(var(--toolbar-border));
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
}
|
||||
|
||||
.document-editor-workbench__editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
width: min(360px, 88%);
|
||||
overflow: auto;
|
||||
background: hsl(var(--surface-elevated));
|
||||
border-left: 1px solid hsl(var(--line-subtle));
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close,
|
||||
.document-editor-workbench__tree-mobile-head,
|
||||
.document-editor-workbench__mobile-trigger,
|
||||
.document-editor-workbench__scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close {
|
||||
position: sticky;
|
||||
top: var(--space-2);
|
||||
z-index: 1;
|
||||
float: right;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
margin: var(--space-2);
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.document-inspector-enter-active,
|
||||
.document-inspector-leave-active {
|
||||
transition:
|
||||
opacity var(--motion-duration-base) var(--motion-ease-standard),
|
||||
transform var(--motion-duration-medium) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.document-inspector-enter-from,
|
||||
.document-inspector-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.document-editor-workbench {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.document-editor-workbench__mobile-trigger {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--nav-item-active));
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.document-editor-workbench__mobile-trigger:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-tool-hover));
|
||||
}
|
||||
|
||||
.document-editor-workbench__mobile-trigger:focus-visible,
|
||||
.document-editor-workbench__tree-mobile-head button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 6;
|
||||
visibility: hidden;
|
||||
width: min(320px, 88%);
|
||||
box-shadow: var(--shadow-float);
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
transition:
|
||||
visibility 0s linear var(--motion-duration-medium),
|
||||
opacity var(--motion-duration-base) var(--motion-ease-standard),
|
||||
transform var(--motion-duration-medium) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree.is-mobile-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree-mobile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree-mobile-head button {
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.document-editor-workbench__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--overlay));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.document-editor-workbench__toolbar {
|
||||
padding-left: calc(var(--space-8) * 3 + var(--space-2));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.document-editor-workbench__tree,
|
||||
.document-inspector-enter-active,
|
||||
.document-inspector-leave-active {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorSaveState } from '@easyflow-core/editor-ui';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
column?: number;
|
||||
encoding?: string;
|
||||
language?: string;
|
||||
line?: number;
|
||||
lines?: number;
|
||||
mode?: 'code' | 'document';
|
||||
saveState?: EditorSaveState;
|
||||
sizeLabel?: string;
|
||||
wordCount?: number;
|
||||
}>(),
|
||||
{
|
||||
column: 1,
|
||||
encoding: 'utf8',
|
||||
language: 'Text',
|
||||
line: 1,
|
||||
lines: 1,
|
||||
mode: 'code',
|
||||
saveState: 'saved',
|
||||
sizeLabel: '0 B',
|
||||
wordCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const saveLabels: Record<EditorSaveState, string> = {
|
||||
dirty: '未保存',
|
||||
error: '保存失败',
|
||||
saved: '已保存',
|
||||
saving: '保存中',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="editor-status-bar" aria-live="polite">
|
||||
<div v-if="mode === 'code'">
|
||||
<span>{{ language }}</span>
|
||||
<span>{{ encoding }}</span>
|
||||
<span>{{ sizeLabel }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span>{{ wordCount }} 字</span>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="mode === 'code'">行 {{ line }},列 {{ column }}</span>
|
||||
<span v-if="mode === 'code'">{{ lines }} 行</span>
|
||||
<span class="editor-status-bar__save" :class="`is-${saveState}`">
|
||||
{{ saveLabels[saveState] }}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-status-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-top: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.editor-status-bar > div {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-status-bar__save.is-dirty,
|
||||
.editor-status-bar__save.is-saving {
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.editor-status-bar__save.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.editor-status-bar > div:first-child span:not(:first-child),
|
||||
.editor-status-bar > div:last-child span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentFileNode } from './types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
File,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
Ellipsis,
|
||||
Plus,
|
||||
} from '@easyflow/icons';
|
||||
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
currentPath?: string;
|
||||
dirtyPaths?: Set<string>;
|
||||
errorCounts?: Record<string, number>;
|
||||
focusedPath?: string;
|
||||
level?: number;
|
||||
node: DocumentFileNode;
|
||||
readonly?: boolean;
|
||||
warningCounts?: Record<string, number>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [node: DocumentFileNode];
|
||||
download: [node: DocumentFileNode];
|
||||
focusPath: [path: string];
|
||||
remove: [node: DocumentFileNode];
|
||||
rename: [node: DocumentFileNode];
|
||||
select: [node: DocumentFileNode];
|
||||
upload: [node: DocumentFileNode];
|
||||
}>();
|
||||
|
||||
const expanded = ref(true);
|
||||
const menuOpen = ref(false);
|
||||
const menuRef = ref<HTMLElement>();
|
||||
const isDirectory = computed(() => props.node.type === 'DIRECTORY');
|
||||
const isRootSkill = computed(() => props.node.path === 'SKILL.md');
|
||||
const isCurrent = computed(() => props.node.path === props.currentPath);
|
||||
const isFocusTarget = computed(
|
||||
() =>
|
||||
props.node.path === props.focusedPath ||
|
||||
(isDirectory.value &&
|
||||
!expanded.value &&
|
||||
Boolean(props.focusedPath?.startsWith(`${props.node.path}/`))),
|
||||
);
|
||||
const icon = computed(() => {
|
||||
if (isDirectory.value) return expanded.value ? FolderOpen : FolderClosed;
|
||||
const extension = props.node.name.split('.').pop()?.toLowerCase();
|
||||
if (props.node.name === 'SKILL.md' || extension === 'md') return FileText;
|
||||
if (['js', 'py', 'sh'].includes(extension || '')) return FileCode2;
|
||||
if (['gif', 'jpeg', 'jpg', 'png', 'webp'].includes(extension || ''))
|
||||
return FileImage;
|
||||
return File;
|
||||
});
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
menuOpen.value = false;
|
||||
});
|
||||
|
||||
function activate() {
|
||||
emit('focusPath', props.node.path);
|
||||
if (isDirectory.value) {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
emit('select', props.node);
|
||||
}
|
||||
|
||||
function runAction(action: 'download' | 'remove' | 'rename') {
|
||||
menuOpen.value = false;
|
||||
if (action === 'download') emit('download', props.node);
|
||||
else if (action === 'remove') emit('remove', props.node);
|
||||
else emit('rename', props.node);
|
||||
}
|
||||
|
||||
function focusTreeItem(target?: HTMLElement | null) {
|
||||
if (!target) return;
|
||||
const tree = target.closest<HTMLElement>('[role="tree"]');
|
||||
tree
|
||||
?.querySelectorAll<HTMLElement>('[role="treeitem"]')
|
||||
.forEach((item) => (item.tabIndex = -1));
|
||||
target.tabIndex = 0;
|
||||
emit('focusPath', target.dataset.treePath || '');
|
||||
target.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight' && isDirectory.value) {
|
||||
event.preventDefault();
|
||||
if (!expanded.value) {
|
||||
expanded.value = true;
|
||||
emit('focusPath', props.node.path);
|
||||
return;
|
||||
}
|
||||
const row = event.currentTarget as HTMLElement;
|
||||
const nodeItem = row.closest('.file-tree-node');
|
||||
const group = [...(nodeItem?.children || [])].find(
|
||||
(element) => element.getAttribute('role') === 'group',
|
||||
);
|
||||
const firstNode = group?.children.item(0);
|
||||
const firstChild =
|
||||
firstNode?.querySelector<HTMLElement>('[role="treeitem"]');
|
||||
focusTreeItem(firstChild);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
if (isDirectory.value && expanded.value) {
|
||||
expanded.value = false;
|
||||
emit('focusPath', props.node.path);
|
||||
return;
|
||||
}
|
||||
const row = event.currentTarget as HTMLElement;
|
||||
const nodeItem = row.closest('.file-tree-node');
|
||||
const parentNode = nodeItem?.parentElement?.closest('.file-tree-node');
|
||||
const parentRow = [...(parentNode?.children || [])].find((element) =>
|
||||
element.classList.contains('file-tree-node__row-wrap'),
|
||||
);
|
||||
const parentItem =
|
||||
parentRow?.querySelector<HTMLElement>('[role="treeitem"]');
|
||||
focusTreeItem(parentItem);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="file-tree-node" role="none">
|
||||
<div
|
||||
class="file-tree-node__row-wrap"
|
||||
:style="{ '--tree-level': level || 0 }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
class="file-tree-node__row"
|
||||
:class="{ 'is-current': isCurrent }"
|
||||
:data-tree-path="node.path"
|
||||
:aria-current="isCurrent ? 'page' : undefined"
|
||||
:aria-expanded="isDirectory ? expanded : undefined"
|
||||
:aria-level="(level || 0) + 1"
|
||||
:aria-selected="isCurrent"
|
||||
:tabindex="isFocusTarget ? 0 : -1"
|
||||
@click="activate"
|
||||
@focus="emit('focusPath', node.path)"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<ChevronRight
|
||||
v-if="isDirectory"
|
||||
class="file-tree-node__chevron"
|
||||
:class="{ 'is-expanded': expanded }"
|
||||
:size="14"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
class="file-tree-node__icon"
|
||||
:size="15"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="file-tree-node__name">{{ node.name }}</span>
|
||||
<span
|
||||
v-if="dirtyPaths?.has(node.path)"
|
||||
class="file-tree-node__dirty"
|
||||
title="未保存"
|
||||
>
|
||||
<span aria-hidden="true">●</span>
|
||||
<span class="file-tree-node__sr-only">未保存</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="errorCounts?.[node.path]"
|
||||
class="file-tree-node__count is-error"
|
||||
:aria-label="`${errorCounts[node.path]} 个错误`"
|
||||
>
|
||||
{{ errorCounts[node.path] }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="warningCounts?.[node.path]"
|
||||
class="file-tree-node__count is-warning"
|
||||
:aria-label="`${warningCounts[node.path]} 个警告`"
|
||||
>
|
||||
{{ warningCounts[node.path] }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="file-tree-node__actions">
|
||||
<button
|
||||
v-if="isDirectory && !readonly"
|
||||
type="button"
|
||||
aria-label="在此目录新建文件"
|
||||
title="在此目录新建文件"
|
||||
@click.stop="emit('create', node)"
|
||||
>
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
v-if="!isDirectory || !readonly"
|
||||
ref="menuRef"
|
||||
class="file-tree-node__menu-wrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="更多操作"
|
||||
title="更多操作"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="menuOpen"
|
||||
@click.stop="menuOpen = !menuOpen"
|
||||
>
|
||||
<Ellipsis :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="menuOpen" class="file-tree-node__menu" role="menu">
|
||||
<button
|
||||
v-if="isDirectory && !readonly"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="
|
||||
menuOpen = false;
|
||||
emit('upload', node);
|
||||
"
|
||||
>
|
||||
上传资源
|
||||
</button>
|
||||
<button
|
||||
v-if="!isDirectory"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runAction('download')"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<template v-if="!readonly && !isDirectory && !isRootSkill">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runAction('rename')"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="is-danger"
|
||||
@click="runAction('remove')"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
v-if="isDirectory && expanded && node.children?.length"
|
||||
class="file-tree-node__children"
|
||||
role="group"
|
||||
>
|
||||
<FileTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.path"
|
||||
:node="child"
|
||||
:level="(level || 0) + 1"
|
||||
:current-path="currentPath"
|
||||
:focused-path="focusedPath"
|
||||
:dirty-paths="dirtyPaths"
|
||||
:error-counts="errorCounts"
|
||||
:warning-counts="warningCounts"
|
||||
:readonly="readonly"
|
||||
@create="emit('create', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@focus-path="emit('focusPath', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="emit('rename', $event)"
|
||||
@select="emit('select', $event)"
|
||||
@upload="emit('upload', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-node,
|
||||
.file-tree-node__children {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% - 16px);
|
||||
margin: 1px 8px;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.file-tree-node__row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--radius-control) 0
|
||||
calc(var(--radius-control) + var(--tree-level) * 14px);
|
||||
margin: 0;
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap:hover .file-tree-node__row {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap:hover .file-tree-node__row,
|
||||
.file-tree-node__row-wrap:focus-within .file-tree-node__row {
|
||||
padding-right: 66px;
|
||||
}
|
||||
|
||||
.file-tree-node__row.is-current {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.file-tree-node__row:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-tree-node__icon {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.file-tree-node__chevron {
|
||||
flex: none;
|
||||
color: hsl(var(--text-muted));
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.file-tree-node__chevron.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.file-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-tree-node__actions {
|
||||
position: absolute;
|
||||
right: var(--space-1);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap:hover .file-tree-node__actions,
|
||||
.file-tree-node__row-wrap:focus-within .file-tree-node__actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-tree-node__actions button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-1);
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-elevated) / 92%);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.file-tree-node__actions button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-tool-hover));
|
||||
}
|
||||
|
||||
.file-tree-node__actions button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-tree-node__menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-tree-node__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
width: 112px;
|
||||
padding: var(--space-1);
|
||||
background: hsl(var(--surface-elevated));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.file-tree-node__menu button {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.file-tree-node__menu button.is-danger {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.file-tree-node__dirty {
|
||||
font-size: 8px;
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.file-tree-node__sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.file-tree-node__count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.file-tree-node__count.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 12%);
|
||||
}
|
||||
|
||||
.file-tree-node__count.is-warning {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 14%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentFileNode } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Upload } from '@easyflow/icons';
|
||||
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
import FileTreeNode from './FileTreeNode.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
busy?: boolean;
|
||||
currentPath?: string;
|
||||
dirtyPaths?: Set<string>;
|
||||
errorCounts?: Record<string, number>;
|
||||
nodes: DocumentFileNode[];
|
||||
readonly?: boolean;
|
||||
title?: string;
|
||||
warningCounts?: Record<string, number>;
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
currentPath: '',
|
||||
dirtyPaths: () => new Set<string>(),
|
||||
errorCounts: () => ({}),
|
||||
readonly: false,
|
||||
title: 'Skill 文件',
|
||||
warningCounts: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [node?: DocumentFileNode];
|
||||
download: [node: DocumentFileNode];
|
||||
remove: [node: DocumentFileNode];
|
||||
rename: [node: DocumentFileNode];
|
||||
select: [node: DocumentFileNode];
|
||||
upload: [node?: DocumentFileNode];
|
||||
}>();
|
||||
|
||||
const effectiveCurrentPath = computed(
|
||||
() => props.currentPath || props.nodes[0]?.path || '',
|
||||
);
|
||||
const focusedPath = ref('');
|
||||
const createMenuOpen = ref(false);
|
||||
const createMenuRef = ref<HTMLElement>();
|
||||
|
||||
onClickOutside(createMenuRef, () => {
|
||||
createMenuOpen.value = false;
|
||||
});
|
||||
|
||||
watch(
|
||||
effectiveCurrentPath,
|
||||
(path) => {
|
||||
focusedPath.value = path;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function focusTreeItem(items: HTMLElement[], index: number) {
|
||||
const target = items[index];
|
||||
if (!target) return;
|
||||
items.forEach((item) => (item.tabIndex = -1));
|
||||
target.tabIndex = 0;
|
||||
focusedPath.value = target.dataset.treePath || '';
|
||||
target.focus();
|
||||
}
|
||||
|
||||
function handleTreeKeydown(event: KeyboardEvent) {
|
||||
if (!['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key)) return;
|
||||
const tree = event.currentTarget as HTMLElement;
|
||||
const items = [...tree.querySelectorAll<HTMLElement>('[role="treeitem"]')];
|
||||
if (items.length === 0) return;
|
||||
const current = (event.target as HTMLElement).closest<HTMLElement>(
|
||||
'[role="treeitem"]',
|
||||
);
|
||||
const currentIndex = current ? items.indexOf(current) : -1;
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === 'ArrowDown')
|
||||
nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||
if (event.key === 'ArrowUp')
|
||||
nextIndex =
|
||||
currentIndex < 0 ? items.length - 1 : Math.max(currentIndex - 1, 0);
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
if (event.key === 'End') nextIndex = items.length - 1;
|
||||
if (nextIndex >= 0) {
|
||||
event.preventDefault();
|
||||
focusTreeItem(items, nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function runHeaderAction(action: 'create' | 'upload') {
|
||||
createMenuOpen.value = false;
|
||||
if (action === 'create') emit('create');
|
||||
else emit('upload');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="file-tree-panel">
|
||||
<header class="file-tree-panel__header">
|
||||
<strong>{{ title }}</strong>
|
||||
<div
|
||||
v-if="!readonly"
|
||||
ref="createMenuRef"
|
||||
class="file-tree-panel__actions"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="添加资源"
|
||||
title="添加资源"
|
||||
:disabled="busy"
|
||||
:aria-expanded="createMenuOpen"
|
||||
aria-haspopup="menu"
|
||||
@click="createMenuOpen = !createMenuOpen"
|
||||
>
|
||||
<Plus :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="createMenuOpen" class="file-tree-panel__menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runHeaderAction('create')"
|
||||
>
|
||||
<Plus :size="15" aria-hidden="true" />
|
||||
新建文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runHeaderAction('upload')"
|
||||
>
|
||||
<Upload :size="15" aria-hidden="true" />
|
||||
上传资源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="file-tree-panel__body" aria-label="Skill 文件树">
|
||||
<ul
|
||||
v-if="nodes.length > 0"
|
||||
class="file-tree-panel__list"
|
||||
role="tree"
|
||||
aria-label="Skill 文件"
|
||||
@keydown="handleTreeKeydown"
|
||||
>
|
||||
<FileTreeNode
|
||||
v-for="node in nodes"
|
||||
:key="node.path"
|
||||
:node="node"
|
||||
:current-path="effectiveCurrentPath"
|
||||
:focused-path="focusedPath"
|
||||
:dirty-paths="dirtyPaths"
|
||||
:error-counts="errorCounts"
|
||||
:warning-counts="warningCounts"
|
||||
:readonly="readonly"
|
||||
@create="emit('create', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@focus-path="focusedPath = $event"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="emit('rename', $event)"
|
||||
@select="emit('select', $event)"
|
||||
@upload="emit('upload', $event)"
|
||||
/>
|
||||
</ul>
|
||||
<div v-else class="file-tree-panel__empty">暂无文件</div>
|
||||
</nav>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-tree-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.file-tree-panel__actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-tree-panel__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
right: 0;
|
||||
z-index: 8;
|
||||
display: grid;
|
||||
width: 136px;
|
||||
padding: var(--space-1);
|
||||
background: hsl(var(--surface-elevated));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.file-tree-panel__menu button {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.file-tree-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2) 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-tree-panel__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-tree-panel__empty {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: hsl(var(--text-muted));
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentValidationIssue } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
issues: DocumentValidationIssue[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
locate: [issue: DocumentValidationIssue];
|
||||
}>();
|
||||
|
||||
const errorCount = computed(
|
||||
() => props.issues.filter((issue) => issue.severity === 'ERROR').length,
|
||||
);
|
||||
const warningCount = computed(
|
||||
() => props.issues.filter((issue) => issue.severity === 'WARNING').length,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="validation-panel">
|
||||
<header class="validation-panel__header">
|
||||
<div>
|
||||
<h3>校验结果</h3>
|
||||
<p>{{ errorCount }} 个错误 · {{ warningCount }} 个警告</p>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="issues.length > 0" class="validation-panel__list">
|
||||
<button
|
||||
v-for="(issue, index) in issues"
|
||||
:key="`${issue.path}-${issue.line}-${issue.code}-${index}`"
|
||||
type="button"
|
||||
class="validation-panel__item"
|
||||
:class="`is-${issue.severity.toLowerCase()}`"
|
||||
@click="emit('locate', issue)"
|
||||
>
|
||||
<span class="validation-panel__severity">
|
||||
{{
|
||||
issue.severity === 'ERROR'
|
||||
? '错误'
|
||||
: issue.severity === 'WARNING'
|
||||
? '警告'
|
||||
: '提示'
|
||||
}}
|
||||
</span>
|
||||
<strong>{{ issue.message }}</strong>
|
||||
<small v-if="issue.path">
|
||||
{{ issue.path
|
||||
}}<template v-if="issue.line">:{{ issue.line }}</template>
|
||||
</small>
|
||||
<span v-if="issue.suggestion" class="validation-panel__suggestion">
|
||||
建议:{{ issue.suggestion }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="validation-panel__empty">
|
||||
<strong>校验通过</strong>
|
||||
<span>未发现需要处理的问题。</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.validation-panel {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.validation-panel__header {
|
||||
padding: var(--space-6);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.validation-panel__header h3,
|
||||
.validation-panel__header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.validation-panel__header p {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.validation-panel__list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.validation-panel__item {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: hsl(var(--foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-toolbar);
|
||||
}
|
||||
|
||||
.validation-panel__item:hover {
|
||||
border-color: hsl(var(--primary) / 42%);
|
||||
}
|
||||
|
||||
.validation-panel__item:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.validation-panel__severity {
|
||||
width: fit-content;
|
||||
padding: 2px 7px;
|
||||
font-size: 11px;
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.validation-panel__item.is-error .validation-panel__severity {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 12%);
|
||||
}
|
||||
|
||||
.validation-panel__item.is-warning .validation-panel__severity {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 14%);
|
||||
}
|
||||
|
||||
.validation-panel__item small {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.validation-panel__suggestion {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
}
|
||||
|
||||
.validation-panel__empty {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: calc(var(--space-6) * 2) var(--space-6);
|
||||
color: hsl(var(--text-muted));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.validation-panel__empty strong {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import FileTreePanel from '../FileTreePanel.vue';
|
||||
|
||||
const nodes = [
|
||||
{ name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
|
||||
{
|
||||
children: [
|
||||
{ name: 'guide.md', path: 'references/guide.md', type: 'REFERENCE' },
|
||||
{
|
||||
children: [
|
||||
{
|
||||
name: 'deep.md',
|
||||
path: 'references/nested/deep.md',
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
],
|
||||
name: 'nested',
|
||||
path: 'references/nested',
|
||||
type: 'DIRECTORY',
|
||||
},
|
||||
],
|
||||
name: 'references',
|
||||
path: 'references',
|
||||
type: 'DIRECTORY',
|
||||
},
|
||||
{
|
||||
children: [{ name: 'run.py', path: 'scripts/run.py', type: 'SCRIPT' }],
|
||||
name: 'scripts',
|
||||
path: 'scripts',
|
||||
type: 'DIRECTORY',
|
||||
},
|
||||
];
|
||||
|
||||
const wrappers: Array<ReturnType<typeof mount>> = [];
|
||||
|
||||
function mountTree(currentPath = 'SKILL.md') {
|
||||
const wrapper = mount(FileTreePanel, {
|
||||
attachTo: document.body,
|
||||
props: { currentPath, nodes },
|
||||
});
|
||||
wrappers.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function treeItem(path: string) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`[role="treeitem"][data-tree-path="${path}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function expectFocused(path: string) {
|
||||
const target = treeItem(path);
|
||||
expect(document.activeElement).toBe(target);
|
||||
expect(target?.tabIndex).toBe(0);
|
||||
expect(
|
||||
document.querySelectorAll('[role="treeitem"][tabindex="0"]'),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('file tree panel keyboard navigation', () => {
|
||||
it('moves through every visible node with ArrowUp, ArrowDown, Home and End', async () => {
|
||||
mountTree();
|
||||
treeItem('SKILL.md')?.focus();
|
||||
|
||||
await treeItem('SKILL.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowDown' }),
|
||||
);
|
||||
expectFocused('references');
|
||||
|
||||
treeItem('scripts/run.py')?.focus();
|
||||
treeItem('scripts/run.py')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Home' }),
|
||||
);
|
||||
expectFocused('SKILL.md');
|
||||
|
||||
treeItem('SKILL.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'End' }),
|
||||
);
|
||||
expectFocused('scripts/run.py');
|
||||
|
||||
treeItem('scripts/run.py')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowUp' }),
|
||||
);
|
||||
expectFocused('scripts');
|
||||
});
|
||||
|
||||
it('opens, enters, leaves and closes nested directories with ArrowRight and ArrowLeft', async () => {
|
||||
mountTree();
|
||||
treeItem('references')?.focus();
|
||||
|
||||
treeItem('references')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
expectFocused('references/guide.md');
|
||||
|
||||
treeItem('references/nested')?.focus();
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
expectFocused('references/nested/deep.md');
|
||||
|
||||
treeItem('references/nested/deep.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }),
|
||||
);
|
||||
expectFocused('references/nested');
|
||||
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(treeItem('references/nested')?.getAttribute('aria-expanded')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(treeItem('references/nested/deep.md')).toBeNull();
|
||||
expectFocused('references/nested');
|
||||
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(treeItem('references/nested')?.getAttribute('aria-expanded')).toBe(
|
||||
'true',
|
||||
);
|
||||
expectFocused('references/nested');
|
||||
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
expectFocused('references/nested/deep.md');
|
||||
});
|
||||
|
||||
it('selects files and directories with Enter or Space', async () => {
|
||||
const wrapper = mountTree();
|
||||
|
||||
treeItem('references/guide.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
|
||||
);
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({
|
||||
path: 'references/guide.md',
|
||||
});
|
||||
|
||||
treeItem('references')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: ' ' }),
|
||||
);
|
||||
await nextTick();
|
||||
expect(treeItem('references')?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(wrapper.emitted('select')).toHaveLength(2);
|
||||
expect(wrapper.emitted('select')?.[1]?.[0]).toMatchObject({
|
||||
path: 'references',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits contextual create and file operations from row actions', async () => {
|
||||
const wrapper = mountTree();
|
||||
const referenceRow = treeItem('references')?.parentElement;
|
||||
|
||||
await referenceRow
|
||||
?.querySelector<HTMLButtonElement>('[aria-label="在此目录新建文件"]')
|
||||
?.click();
|
||||
expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
|
||||
path: 'references',
|
||||
});
|
||||
|
||||
const guideRow = treeItem('references/guide.md')?.parentElement;
|
||||
guideRow
|
||||
?.querySelector<HTMLButtonElement>('[aria-label="更多操作"]')
|
||||
?.click();
|
||||
await nextTick();
|
||||
const download = [...(guideRow?.querySelectorAll('button') || [])].find(
|
||||
(button) => button.textContent?.trim() === '下载',
|
||||
);
|
||||
download?.click();
|
||||
expect(wrapper.emitted('download')?.[0]?.[0]).toMatchObject({
|
||||
path: 'references/guide.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the single tab stop when the selected path changes', async () => {
|
||||
const wrapper = mountTree();
|
||||
|
||||
await wrapper.setProps({ currentPath: 'scripts/run.py' });
|
||||
|
||||
expect(treeItem('scripts/run.py')?.tabIndex).toBe(0);
|
||||
expect(
|
||||
document.querySelectorAll('[role="treeitem"][tabindex="0"]'),
|
||||
).toHaveLength(1);
|
||||
expect(treeItem('scripts/run.py')?.getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as DocumentEditorWorkbench } from './DocumentEditorWorkbench.vue';
|
||||
export { default as EditorStatusBar } from './EditorStatusBar.vue';
|
||||
export { default as FileTreePanel } from './FileTreePanel.vue';
|
||||
export type * from './types';
|
||||
export { default as ValidationPanel } from './ValidationPanel.vue';
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface DocumentFileNode {
|
||||
children?: DocumentFileNode[];
|
||||
key?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DocumentValidationIssue {
|
||||
code?: string;
|
||||
column?: number;
|
||||
line?: number;
|
||||
message: string;
|
||||
path?: string;
|
||||
severity: 'ERROR' | 'INFO' | 'WARNING' | string;
|
||||
suggestion?: string;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from './chat-thinking';
|
||||
export * from './chat-timeline';
|
||||
export * from './col-page';
|
||||
export * from './count-to';
|
||||
export * from './document-editor';
|
||||
export * from './ellipsis-text';
|
||||
export * from './icon-picker';
|
||||
export * from './json-viewer';
|
||||
|
||||
Reference in New Issue
Block a user