feat: 重构 Skill 管理与编辑工作台

- 统一列表、分类、新建与详情页的产品交互

- 提供 Markdown 实时编辑、源码与脚本资源工作台

- 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
2026-07-27 18:54:57 +08:00
parent 2a9e882ac6
commit 0dc5c3ca55
78 changed files with 16413 additions and 799 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
);
});
});

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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';