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:
2026-10-18 22:57:25 +08:00
parent c3eeea4187
commit 836d0228d2
115 changed files with 4067 additions and 3891 deletions
@@ -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>