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
+55 -42
View File
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import ArcoVue from '@arco-design/web-vue'
import type { FileEntry } from '@/api/hermes/files'
const mockMessage = vi.hoisted(() => ({
@@ -22,36 +24,6 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', async () => {
const { defineComponent, h } = await import('vue')
return {
NDropdown: defineComponent({
name: 'NDropdown',
props: {
show: { type: Boolean, default: false },
options: { type: Array, default: () => [] },
},
emits: ['select', 'clickoutside'],
setup(props, { emit }) {
return () => h('div', { 'data-show': props.show ? 'true' : 'false' },
(props.options as Array<any>).map(option => h(
'button',
{
type: 'button',
'data-key': option.key,
onClick: () => emit('select', option.key),
},
option.label ?? option.type ?? option.key,
)),
)
},
}),
useMessage: () => mockMessage,
useDialog: () => mockDialog,
}
})
vi.mock('@/api/hermes/download', () => ({
downloadFile: downloadFileMock,
}))
@@ -64,6 +36,41 @@ vi.mock('@/utils/file-path', () => ({
getClipboardPathForEntry: (entry: FileEntry) => entry.path,
}))
// Arco's imperative Message/Dialog APIs need a live DOM, so mock the adapters.
vi.mock('@/composables/useMessage', () => ({ useMessage: () => mockMessage }))
vi.mock('@/composables/useDialog', () => ({ useDialog: () => mockDialog }))
/**
* Arco's a-trigger teleports its popup to body in jsdom and a-doption is a
* popup-scoped component, so both are stubbed under Arco's *internal* component
* names — stubs key off those, not the `a-*` template tags, because Arco is
* registered globally via `app.use(ArcoVue)`.
*
* The stub renders the #content slot inline and lets clicks bubble from the
* option element, so a click behaves like the real Doption -> handleSelect path.
*/
const ATriggerStub = defineComponent({
name: 'Trigger',
props: ['popupVisible'],
template: '<div class="arco-trigger-stub"><slot /><slot name="content" /></div>',
})
const ADoptionStub = defineComponent({
name: 'Doption',
emits: ['click'],
template: '<div class="arco-doption-stub" @click="$emit(\'click\')"><slot /></div>',
})
const arcoGlobal = {
plugins: [ArcoVue],
stubs: { Trigger: ATriggerStub, Doption: ADoptionStub },
}
/** Menu item keys, read from the rendered popup rather than a stub attribute. */
function menuKeys(wrapper: ReturnType<typeof mount>): string[] {
return wrapper.findAll('.file-context-menu > *').map(node => node.text().trim())
}
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
import { useFilesStore } from '@/stores/hermes/files'
@@ -78,8 +85,14 @@ describe('FileContextMenu', () => {
await flushPromises()
}
function menuItem(wrapper: ReturnType<typeof mount>, label: string) {
const item = wrapper.findAll('.file-context-menu > *').find(node => node.text().trim() === label)
if (!item) throw new Error(`menu item not found: ${label}`)
return item
}
it('offers both edit and preview for previewable text files', async () => {
const wrapper = mount(FileContextMenu)
const wrapper = mount(FileContextMenu, { global: arcoGlobal })
const entry: FileEntry = {
name: 'Dockerfile',
path: 'Dockerfile',
@@ -90,16 +103,16 @@ describe('FileContextMenu', () => {
await showMenu(wrapper, entry)
const keys = wrapper.findAll('[data-key]').map(node => node.attributes('data-key'))
expect(keys).toContain('edit')
expect(keys).toContain('preview')
expect(keys).toContain('download')
const labels = menuKeys(wrapper)
expect(labels).toContain('files.edit')
expect(labels).toContain('files.preview')
expect(labels).toContain('files.download')
})
it('invokes the store preview action from the preview menu item', async () => {
const store = useFilesStore()
const openPreviewSpy = vi.spyOn(store, 'openPreview').mockResolvedValue(undefined)
const wrapper = mount(FileContextMenu)
const wrapper = mount(FileContextMenu, { global: arcoGlobal })
const entry: FileEntry = {
name: 'README',
path: 'README',
@@ -109,7 +122,7 @@ describe('FileContextMenu', () => {
}
await showMenu(wrapper, entry)
await wrapper.get('[data-key="preview"]').trigger('click')
await menuItem(wrapper, 'files.preview').trigger('click')
await flushPromises()
expect(openPreviewSpy).toHaveBeenCalledWith(entry)
@@ -117,7 +130,7 @@ describe('FileContextMenu', () => {
})
it('does not offer preview for non-previewable binary files', async () => {
const wrapper = mount(FileContextMenu)
const wrapper = mount(FileContextMenu, { global: arcoGlobal })
const entry: FileEntry = {
name: 'archive.zip',
path: 'archive.zip',
@@ -128,9 +141,9 @@ describe('FileContextMenu', () => {
await showMenu(wrapper, entry)
const keys = wrapper.findAll('[data-key]').map(node => node.attributes('data-key'))
expect(keys).not.toContain('preview')
expect(keys).not.toContain('edit')
expect(keys).toContain('download')
const labels = menuKeys(wrapper)
expect(labels).not.toContain('files.preview')
expect(labels).not.toContain('files.edit')
expect(labels).toContain('files.download')
})
})