Phase 007: migrate every remaining view from naive-ui to Arco Design Vue
The project now has exactly one UI library. Every naive-ui component in
packages/client/src is replaced by its Arco equivalent across 24 views and
roughly 80 components, and naive-ui is dropped from package.json along with
its transitive vueuc / @css-render / vdirs / seemly dependencies.
The migration is rendering-layer only. No store logic, no API contract, and
no /chat-run payload was touched.
Three classes of leftover were invisible to `grep naive-ui` and are now
covered by tests/client/no-naive-ui.test.ts, which asserts all three:
- dead .n-* class names in style blocks
- dead --n-* custom properties
- naive-ui in package.json
Notable behavioural traps handled along the way, recorded in
docs/arco-migration-rules.md:
- a-input-password defaults to masked; passing default-visibility="false"
would have exposed API keys in clear text
- a-upload fires a real XHR unless auto-upload is false
- nested Trigger components fight over the first element child, so the
group-chat settings dropdown was flattened
- Arco renders tooltip default-slot text inline, which was silently
collapsing the chat and group-chat toolbars
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -18,18 +17,18 @@ function handleClick(index: number) {
|
||||
|
||||
<template>
|
||||
<div class="file-breadcrumb">
|
||||
<NBreadcrumb>
|
||||
<NBreadcrumbItem @click="handleClick(-1)">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item @click="handleClick(-1)">
|
||||
{{ t('files.breadcrumbRoot') }}
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item
|
||||
v-for="(segment, index) in filesStore.pathSegments"
|
||||
:key="index"
|
||||
@click="handleClick(index)"
|
||||
>
|
||||
{{ segment }}
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { NDropdown } from 'naive-ui'
|
||||
import { ref, nextTick, computed } from 'vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -25,10 +24,18 @@ const emit = defineEmits<{
|
||||
(e: 'newFolder', entry: FileEntry): void
|
||||
}>()
|
||||
|
||||
// Arco's a-trigger has no x/y props. The popup is teleported to body with
|
||||
// position:absolute, so page coordinates are the right basis here; the
|
||||
// computed style overrides whatever getPopupStyle() derived from the anchor.
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuX.value}px`,
|
||||
top: `${menuY.value}px`,
|
||||
}))
|
||||
|
||||
function show(e: MouseEvent, entry: FileEntry) {
|
||||
targetEntry.value = entry
|
||||
menuX.value = e.clientX
|
||||
menuY.value = e.clientY
|
||||
menuX.value = e.pageX
|
||||
menuY.value = e.pageY
|
||||
showMenu.value = false
|
||||
nextTick(() => {
|
||||
showMenu.value = true
|
||||
@@ -115,22 +122,44 @@ async function handleSelect(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown
|
||||
:show="showMenu"
|
||||
:x="menuX"
|
||||
:y="menuY"
|
||||
:options="getOptions()"
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
@select="handleSelect"
|
||||
@clickoutside="handleClickOutside"
|
||||
/>
|
||||
<a-trigger
|
||||
trigger="contextMenu"
|
||||
position="bl"
|
||||
v-model:popup-visible="showMenu"
|
||||
:popup-style="menuStyle"
|
||||
:auto-fit-position="false"
|
||||
>
|
||||
<span class="file-context-menu-anchor" />
|
||||
<template #content>
|
||||
<div class="file-context-menu">
|
||||
<template v-for="option in getOptions()" :key="option.key">
|
||||
<div v-if="option.type === 'divider'" class="file-context-divider" />
|
||||
<a-doption v-else @click="handleSelect(option.key)">
|
||||
{{ option.label }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</a-trigger>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-context-menu-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-context-menu {
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.file-context-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { NButton, NSpace } from 'naive-ui'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -97,14 +96,14 @@ function handleClose() {
|
||||
<div class="file-editor">
|
||||
<div class="editor-header">
|
||||
<span class="editor-filename">{{ filesStore.editingFile?.path }}</span>
|
||||
<NSpace>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">
|
||||
<a-space>
|
||||
<a-button size="small" type="primary" :loading="saving" @click="handleSave">
|
||||
{{ t('files.saveFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleClose">
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleClose">
|
||||
{{ t('files.closeEditor') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div ref="editorContainer" class="editor-container" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, NEmpty } from 'naive-ui'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore, isPreviewableFile, isTextFile } from '@/stores/hermes/files'
|
||||
@@ -80,8 +79,8 @@ async function handleDownload(entry: FileEntry) {
|
||||
|
||||
<template>
|
||||
<div class="file-list">
|
||||
<NSpin :show="filesStore.loading">
|
||||
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
|
||||
<a-spin :loading="filesStore.loading">
|
||||
<a-empty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
|
||||
<div v-else class="file-list-items">
|
||||
<div class="file-list-header file-list-grid">
|
||||
<div class="file-name sort-header" @click="filesStore.setSort('name')">
|
||||
@@ -112,13 +111,13 @@ async function handleDownload(entry: FileEntry) {
|
||||
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
|
||||
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
|
||||
<div class="file-actions">
|
||||
<NButton v-if="isPreviewableFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="handlePreview(entry)" :title="t('files.preview')">👁️</NButton>
|
||||
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')">✏️</NButton>
|
||||
<NButton v-if="!filesStore.currentWorkspaceSessionId && !entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')">⬇️</NButton>
|
||||
<a-button v-if="isPreviewableFile(entry.name) && !entry.isDir" size="mini" type="text" @click.stop="handlePreview(entry)" :title="t('files.preview')">👁️</a-button>
|
||||
<a-button v-if="isTextFile(entry.name) && !entry.isDir" size="mini" type="text" @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')">✏️</a-button>
|
||||
<a-button v-if="!filesStore.currentWorkspaceSessionId && !entry.isDir" size="mini" type="text" @click.stop="handleDownload(entry)" :title="t('files.download')">⬇️</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h } from 'vue'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
@@ -46,12 +45,12 @@ const CloseIcon = () =>
|
||||
<div class="file-preview" v-if="filesStore.previewFile">
|
||||
<div class="preview-header">
|
||||
<span class="preview-filename">{{ filesStore.previewFile.path }}</span>
|
||||
<NButton size="small" quaternary @click="filesStore.closePreview()">
|
||||
<a-button size="small" type="text" @click="filesStore.closePreview()">
|
||||
<template #icon>
|
||||
<NIcon><CloseIcon /></NIcon>
|
||||
<CloseIcon />
|
||||
</template>
|
||||
{{ t('files.closePreview') }}
|
||||
</NButton>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<img
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { NModal, NInput, NButton, NSpace } from 'naive-ui'
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
import type { InputInstance } from '@arco-design/web-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
@@ -23,6 +23,7 @@ const emit = defineEmits<{
|
||||
|
||||
const inputValue = ref('')
|
||||
const submitting = ref(false)
|
||||
const inputRef = ref<InputInstance | null>(null)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
@@ -31,6 +32,9 @@ watch(() => props.show, (val) => {
|
||||
} else {
|
||||
inputValue.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,20 +85,26 @@ async function handleSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="props.show" preset="dialog" :title="title" @update:show="emit('update:show', false)" style="width: 400px;">
|
||||
<NInput
|
||||
v-model:value="inputValue"
|
||||
<a-modal
|
||||
:visible="props.show"
|
||||
:title="title"
|
||||
:width="400"
|
||||
unmount-on-close
|
||||
@update:visible="emit('update:show', false)"
|
||||
>
|
||||
<a-input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter="handleSubmit"
|
||||
autofocus
|
||||
/>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton @click="emit('update:show', false)">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button @click="emit('update:show', false)">{{ t('common.cancel') }}</a-button>
|
||||
<a-button type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
|
||||
{{ t('common.ok') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</NModal>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpace } from 'naive-ui'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
@@ -31,20 +30,20 @@ async function handleRefresh() {
|
||||
|
||||
<template>
|
||||
<div class="file-toolbar">
|
||||
<NSpace :size="8" :wrap="true" class="toolbar-space">
|
||||
<NButton size="small" @click="emit('showNewFile')" class="toolbar-btn">
|
||||
<a-space :size="8" :wrap="true" class="toolbar-space">
|
||||
<a-button size="small" @click="emit('showNewFile')" class="toolbar-btn">
|
||||
{{ t('files.newFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showNewFolder')" class="toolbar-btn">
|
||||
</a-button>
|
||||
<a-button size="small" @click="emit('showNewFolder')" class="toolbar-btn">
|
||||
{{ t('files.newFolder') }}
|
||||
</NButton>
|
||||
<NButton v-if="allowUpload" size="small" @click="emit('showUpload')" class="toolbar-btn">
|
||||
</a-button>
|
||||
<a-button v-if="allowUpload" size="small" @click="emit('showUpload')" class="toolbar-btn">
|
||||
{{ t('files.upload') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleRefresh" class="toolbar-btn">
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleRefresh" class="toolbar-btn">
|
||||
{{ t('files.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,9 +60,8 @@ async function handleRefresh() {
|
||||
|
||||
.toolbar-space {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
:deep(.n-space) {
|
||||
gap: 4px !important;
|
||||
}
|
||||
column-gap: 4px !important;
|
||||
row-gap: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, h } from 'vue'
|
||||
import { NTree } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import type { TreeOption } from 'naive-ui'
|
||||
import type { TreeNodeData } from '@arco-design/web-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
@@ -14,12 +13,12 @@ const props = defineProps<{
|
||||
|
||||
const effectiveProfile = computed(() => props.profile === undefined ? filesStore.currentProfile : props.profile)
|
||||
|
||||
const treeData = ref<TreeOption[]>([])
|
||||
const treeData = ref<TreeNodeData[]>([])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const treeInstanceKey = ref(0)
|
||||
let rootLoadSeq = 0
|
||||
|
||||
async function loadChildren(path: string): Promise<TreeOption[]> {
|
||||
async function loadChildren(path: string): Promise<TreeNodeData[]> {
|
||||
try {
|
||||
const result = filesStore.currentWorkspaceSessionId
|
||||
? await filesStore.listEntries(path)
|
||||
@@ -29,7 +28,7 @@ async function loadChildren(path: string): Promise<TreeOption[]> {
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(e => ({
|
||||
key: e.path,
|
||||
label: e.name,
|
||||
title: e.name,
|
||||
isLeaf: false,
|
||||
}))
|
||||
} catch {
|
||||
@@ -37,16 +36,16 @@ async function loadChildren(path: string): Promise<TreeOption[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoad(node: TreeOption): Promise<void> {
|
||||
async function handleLoad(node: TreeNodeData): Promise<void> {
|
||||
const seq = rootLoadSeq
|
||||
const children = await loadChildren(node.key as string)
|
||||
const children = await loadChildren(String(node.key ?? ''))
|
||||
if (seq === rootLoadSeq) node.children = children
|
||||
}
|
||||
|
||||
function handleSelect(keys: string[]) {
|
||||
function handleSelect(keys: (string | number)[]) {
|
||||
if (keys.length > 0) {
|
||||
selectedKeys.value = keys
|
||||
filesStore.navigateTo(keys[0], { profile: effectiveProfile.value })
|
||||
selectedKeys.value = keys.map(String)
|
||||
filesStore.navigateTo(String(keys[0]), { profile: effectiveProfile.value })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +54,6 @@ function handleRootClick() {
|
||||
filesStore.navigateTo('', { profile: effectiveProfile.value })
|
||||
}
|
||||
|
||||
function renderLabel({ option }: { option: TreeOption }) {
|
||||
const label = String(option.label || '')
|
||||
return h('span', { class: 'tree-node-label', title: label }, label)
|
||||
}
|
||||
|
||||
watch([effectiveProfile, () => filesStore.currentWorkspaceSessionId, () => props.workspaceKey], async () => {
|
||||
const seq = ++rootLoadSeq
|
||||
selectedKeys.value = []
|
||||
@@ -78,16 +72,21 @@ watch([effectiveProfile, () => filesStore.currentWorkspaceSessionId, () => props
|
||||
</svg>
|
||||
<span>{{ t('files.breadcrumbRoot') }}</span>
|
||||
</div>
|
||||
<NTree
|
||||
<a-tree
|
||||
:key="treeInstanceKey"
|
||||
:data="treeData"
|
||||
:selected-keys="selectedKeys"
|
||||
:on-load="handleLoad"
|
||||
:render-label="renderLabel"
|
||||
expand-on-click
|
||||
block-line
|
||||
@update:selected-keys="handleSelect"
|
||||
/>
|
||||
:load-more="handleLoad"
|
||||
:default-expand-all="false"
|
||||
action-on-node-click="expand"
|
||||
block-node
|
||||
show-line
|
||||
@select="handleSelect"
|
||||
>
|
||||
<template #title="node">
|
||||
<span class="tree-node-label" :title="node.title">{{ node.title }}</span>
|
||||
</template>
|
||||
</a-tree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NButton, NUpload, NSpace } from 'naive-ui'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import type { FileItem } from '@arco-design/web-vue/es/upload/interfaces'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
@@ -16,9 +15,10 @@ const emit = defineEmits<{ (e: 'update:show', value: boolean): void }>()
|
||||
const uploading = ref(false)
|
||||
const fileList = ref<File[]>([])
|
||||
|
||||
function handleFileChange(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
|
||||
fileList.value = data.fileList
|
||||
.map((f: UploadFileInfo) => f.file)
|
||||
// Arco's change event passes (fileList, fileItem); naive passed { file, fileList }.
|
||||
function handleFileChange(list: FileItem[]) {
|
||||
fileList.value = list
|
||||
.map((f: FileItem) => f.file)
|
||||
.filter((f): f is File => f != null)
|
||||
}
|
||||
|
||||
@@ -44,26 +44,25 @@ function handleClose() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="props.show" preset="dialog" :title="t('files.upload')" @update:show="handleClose" style="width: 500px;">
|
||||
<NUpload
|
||||
<a-modal :visible="props.show" :title="t('files.upload')" :width="500" @update:visible="handleClose">
|
||||
<a-upload
|
||||
multiple
|
||||
directory-dnd
|
||||
:default-upload="false"
|
||||
:auto-upload="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="upload-dragger">
|
||||
<p>{{ t('files.dragDropHint') }}</p>
|
||||
</div>
|
||||
</NUpload>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
|
||||
</a-upload>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button @click="handleClose">{{ t('common.cancel') }}</a-button>
|
||||
<a-button type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
|
||||
{{ t('files.upload') }} ({{ fileList.length }})
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</NModal>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user