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
+105 -60
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { defineComponent, h } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import ArcoVue from '@arco-design/web-vue'
const routeState = vi.hoisted(() => ({
query: { board: 'project-a' } as Record<string, string>,
@@ -51,6 +52,11 @@ vi.mock('vue-i18n', () => ({
}),
}))
// Arco's imperative Message API needs a live DOM, so mock the adapter composable.
vi.mock('@/composables/useMessage', () => ({
useMessage: () => ({ warning: vi.fn(), error: vi.fn(), success: vi.fn(), info: vi.fn() }),
}))
vi.mock('@/stores/hermes/kanban', () => ({
DEFAULT_KANBAN_BOARD: 'default',
useKanbanStore: () => ({
@@ -101,50 +107,84 @@ vi.mock('@/components/hermes/kanban/KanbanCreateForm.vue', () => ({
}),
}))
vi.mock('naive-ui', () => ({
useMessage: () => ({ warning: vi.fn(), error: vi.fn(), success: vi.fn() }),
NButton: defineComponent({
name: 'NButton',
emits: ['click'],
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
}),
NSelect: defineComponent({
name: 'NSelect',
props: { value: null, options: { type: Array, default: () => [] }, loading: Boolean },
emits: ['update:value'],
template: '<button class="n-select-stub" @click="$emit(\'update:value\', options[1]?.value || value)"><span v-for="option in options" :key="option.value">{{ option.label }}</span>{{ value }}</button>',
}),
NInput: defineComponent({
name: 'NInput',
props: { value: { type: String, default: '' }, placeholder: { type: String, required: false } },
emits: ['update:value'],
template: '<input class="n-input-stub" :placeholder="placeholder" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
}),
NModal: defineComponent({
name: 'NModal',
props: { show: Boolean },
emits: ['update:show', 'close'],
template: '<div v-if="show" class="n-modal-stub"><slot /><slot name="action" /></div>',
}),
NSpin: defineComponent({
name: 'NSpin',
template: '<div class="n-spin-stub"><slot /></div>',
}),
NCollapse: defineComponent({
name: 'NCollapse',
props: { expandedNames: { type: Array, required: false }, defaultExpandedNames: { type: Array, required: false } },
emits: ['update:expandedNames'],
template: '<div class="n-collapse-stub" :data-expanded="JSON.stringify(expandedNames ?? null)" :data-default-expanded="JSON.stringify(defaultExpandedNames ?? null)"><slot /></div>',
}),
NCollapseItem: defineComponent({
name: 'NCollapseItem',
props: { title: { type: String, required: false }, name: { type: String, required: false } },
template: '<section class="n-collapse-item-stub" :data-name="name"><slot /></section>',
}),
}))
/**
* Arco's a-select renders a custom dropdown (no native <select>) and a-modal
* teleports to body in jsdom, 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)`.
*/
const ASelectStub = defineComponent({
name: 'Select',
props: {
modelValue: { default: null },
options: { type: Array, default: () => [] },
loading: Boolean,
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
return () =>
h('select', {
class: 'arco-select-stub',
onChange: (event: Event) => {
const value = (event.target as HTMLSelectElement).value
emit('update:modelValue', value)
emit('change', value)
},
}, (props.options as Array<{ label: string; value: string }>).map(option =>
h('option', { key: option.value, value: option.value }, option.label),
))
},
})
const AModalStub = defineComponent({
name: 'Modal',
props: ['visible'],
template: '<div v-if="visible" class="a-modal-stub"><slot /><slot name="footer" /></div>',
})
const arcoGlobal = {
plugins: [ArcoVue],
stubs: { Select: ASelectStub, Modal: AModalStub },
}
/** Toolbar buttons are located by their label, never by DOM order. */
function buttonByText(wrapper: ReturnType<typeof mount>, text: string) {
const button = wrapper.findAll('button').find(node => node.text().trim() === text)
if (!button) throw new Error(`button not found: ${text}`)
return button
}
/**
* Locate a header select by an option value it offers, so the assertion keeps
* targeting the intended filter even if the toolbar order changes.
*/
function selectOffering(wrapper: ReturnType<typeof mount>, value: string) {
const select = wrapper
.findAll('select.arco-select-stub')
.find(node => node.findAll('option').some(option => option.attributes('value') === value))
if (!select) throw new Error(`select offering "${value}" not found`)
return select
}
/** The board column for a status, matched via the `status-<name>` class the view sets. */
function columnFor(wrapper: ReturnType<typeof mount>, status: string) {
return wrapper.get(`.arco-collapse-item.status-${status}`)
}
/** Statuses whose collapse panel is currently expanded. */
function expandedColumns(wrapper: ReturnType<typeof mount>): string[] {
return wrapper
.findAll('.arco-collapse-item')
.filter(item => item.classes().includes('arco-collapse-item-active'))
.map(item => item.classes().find(name => name.startsWith('status-'))?.slice('status-'.length) ?? '')
}
import KanbanView from '@/views/hermes/KanbanView.vue'
function mountView() {
return mount(KanbanView, { global: arcoGlobal })
}
describe('KanbanView', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -197,7 +237,7 @@ describe('KanbanView', () => {
})
it('initializes board from route query and refreshes stats alongside tasks', async () => {
const wrapper = mount(KanbanView)
const wrapper = mountView()
await flushPromises()
expect(mockFetchBoards).toHaveBeenCalledOnce()
@@ -206,7 +246,11 @@ describe('KanbanView', () => {
expect(mockRecoverSelectedBoard).toHaveBeenCalledWith('project-a')
expect(mockRefreshAll).toHaveBeenCalledOnce()
expect(routerReplace).not.toHaveBeenCalled()
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["triage","todo","scheduled","ready","running","blocked","review","done","archived"]')
// Every board status gets a column, and all of them start expanded.
expect(wrapper.findAll('.arco-collapse-item')).toHaveLength(9)
expect(expandedColumns(wrapper)).toEqual([
'triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived',
])
await wrapper.find('.drawer-updated').trigger('click')
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
@@ -222,12 +266,13 @@ describe('KanbanView', () => {
it('renders board count labels and compact assignee profile labels', async () => {
storeState.assignees = [{ name: 'alice', counts: { todo: 2, done: 1 } }]
const wrapper = mount(KanbanView)
const wrapper = mountView()
await flushPromises()
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
const assigneeSelect = wrapper.findAll('.n-select-stub')[2]
// The assignee filter is the select offering the `alice` assignee.
const assigneeSelect = selectOffering(wrapper, 'alice')
expect(assigneeSelect.text()).toContain('alice')
expect(assigneeSelect.text()).not.toContain('default')
expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice')
@@ -238,7 +283,7 @@ describe('KanbanView', () => {
storeState.tasks = [{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10, assignee: 'alice' }]
profilesState.profiles = [{ name: 'alice', avatar: { type: 'generated', seed: 'alice-seed' } }]
const wrapper = mount(KanbanView)
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.kanban-task-card-stub').attributes('data-avatar-seed')).toBe('alice-seed')
@@ -247,13 +292,13 @@ describe('KanbanView', () => {
it('filters the visible board columns from stats chips', async () => {
storeState.filterStatus = 'done'
const wrapper = mount(KanbanView)
const wrapper = mountView()
await flushPromises()
const columns = wrapper.findAll('.n-collapse-item-stub')
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["done"]')
expect(columns).toHaveLength(1)
expect(columns[0].attributes('data-name')).toBe('done')
// Only the filtered status keeps a column, and it is the expanded one.
expect(wrapper.findAll('.arco-collapse-item')).toHaveLength(1)
expect(expandedColumns(wrapper)).toEqual(['done'])
expect(columnFor(wrapper, 'done').classes()).toContain('kanban-column')
expect(wrapper.text()).toContain('Task two')
expect(wrapper.text()).not.toContain('Task one')
@@ -272,22 +317,22 @@ describe('KanbanView', () => {
it('creates and archives boards from the board toolbar', async () => {
storeState.selectedBoard = 'project-a'
const wrapper = mount(KanbanView)
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.n-button-stub')[0].trigger('click')
await buttonByText(wrapper, 'common.add').trigger('click')
await flushPromises()
const inputs = wrapper.findAll('.n-input-stub')
await inputs[0].setValue('new-board')
await inputs[1].setValue('New Board')
await wrapper.findAll('.n-button-stub').at(-1)!.trigger('click')
// The create-board modal is keyed by its own placeholder fields, not index.
await wrapper.get('input[placeholder="kanban.board.slugPlaceholder"]').setValue('new-board')
await wrapper.get('input[placeholder="kanban.board.namePlaceholder"]').setValue('New Board')
await buttonByText(wrapper, 'common.create').trigger('click')
await flushPromises()
expect(mockCreateBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'new-board' } })
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
await wrapper.findAll('.n-button-stub')[1].trigger('click')
await buttonByText(wrapper, 'kanban.board.archive').trigger('click')
await flushPromises()
expect(mockArchiveSelectedBoard).toHaveBeenCalled()
@@ -301,7 +346,7 @@ describe('KanbanView', () => {
return { board: 'default', recovered: false }
})
mount(KanbanView)
mountView()
await flushPromises()
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })