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
+147 -61
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 mockCreateTask = vi.hoisted(() => vi.fn())
const mockMessage = vi.hoisted(() => ({
@@ -42,43 +43,131 @@ vi.mock('@/api/hermes/skills', () => ({
fetchSkills: mockFetchSkills,
}))
vi.mock('naive-ui', () => ({
NModal: defineComponent({
emits: ['close'],
template: '<div class="n-modal-stub"><slot /><slot name="action" /></div>',
}),
NForm: defineComponent({ template: '<form><slot /></form>' }),
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
NInput: defineComponent({
props: { value: { type: String, required: false } },
emits: ['update:value'],
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
}),
NInputNumber: defineComponent({
props: { value: { type: Number, required: false } },
emits: ['update:value'],
template: '<input class="n-input-number-stub" type="number" :value="value ?? \'\'" @input="$emit(\'update:value\', $event.target.value === \'\' ? null : Number($event.target.value))" />',
}),
NSelect: defineComponent({
props: { value: { required: false }, options: { type: Array, default: () => [] }, multiple: { type: Boolean, default: false } },
emits: ['update:value'],
template: '<select class="n-select-stub" :multiple="multiple" @change="$emit(\'update:value\', multiple ? Array.from($event.target.selectedOptions).map(option => option.value) : ($event.target.value === \'\' ? null : (/^\\d+$/.test($event.target.value) ? Number($event.target.value) : $event.target.value)))"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
}),
NButton: defineComponent({
emits: ['click'],
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
}),
NCheckbox: defineComponent({
props: { checked: { type: Boolean, required: false } },
emits: ['update:checked'],
template: '<label class="n-checkbox-stub"><input type="checkbox" :checked="checked" @change="$emit(\'update:checked\', $event.target.checked)" /><slot /></label>',
}),
useMessage: () => mockMessage,
}))
// KanbanCreateForm reads messages via the Arco adapter layer, not naive-ui.
vi.mock('@/composables/useMessage', () => ({ useMessage: () => mockMessage }))
/**
* Arco's a-select/a-input-number render custom widgets (no native form
* controls) and a-modal teleports to body in jsdom, so they 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: () => [] },
multiple: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
return () =>
h('select', {
class: 'arco-select-stub',
multiple: props.multiple,
onChange: (event: Event) => {
const target = event.target as HTMLSelectElement
const raw = props.multiple
? Array.from(target.selectedOptions).map(option => option.value)
: target.value
// Options carry non-string values (e.g. numeric priority); restore the
// declared value type so the component receives what it would in prod.
const options = props.options as Array<{ label: string; value: unknown }>
const typed = (value: string) =>
options.find(option => String(option.value) === value)?.value ?? value
const value = props.multiple
? (raw as string[]).map(typed)
: typed(raw as string)
emit('update:modelValue', value)
emit('change', value)
},
}, [
h('option', { value: '' }),
...(props.options as Array<{ label: string; value: unknown }>).map(option =>
h('option', { key: String(option.value), value: String(option.value) }, option.label),
),
])
},
})
const AInputNumberStub = defineComponent({
name: 'InputNumber',
props: { modelValue: { type: [Number, String], default: undefined } },
emits: ['update:modelValue', 'change'],
setup(_props, { emit }) {
return () =>
h('input', {
class: 'arco-input-number-stub',
type: 'number',
onChange: (event: Event) => {
const raw = (event.target as HTMLInputElement).value
const value = raw === '' ? undefined : Number(raw)
emit('update:modelValue', value)
emit('change', value)
},
})
},
})
const ACheckboxStub = defineComponent({
name: 'Checkbox',
props: { modelValue: Boolean },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h('label', { class: 'arco-checkbox-stub' }, [
h('input', {
type: 'checkbox',
checked: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
}),
h('span', {}, 'checkbox-slot'),
])
},
})
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,
InputNumber: AInputNumberStub,
Checkbox: ACheckboxStub,
Modal: AModalStub,
},
}
/**
* Locate a form control by its Arco form-item label instead of by DOM index, so
* the assertion keeps targeting the intended field even if field order changes.
*/
function fieldByLabel(wrapper: ReturnType<typeof mount>, label: string) {
const item = wrapper
.findAll('.arco-form-item')
.find(node => node.find('.arco-form-item-label').text().trim() === label)
if (!item) throw new Error(`form item not found: ${label}`)
return item
}
function selectByLabel(wrapper: ReturnType<typeof mount>, label: string) {
return fieldByLabel(wrapper, label).get('select.arco-select-stub')
}
function inputNumberByLabel(wrapper: ReturnType<typeof mount>, label: string) {
return fieldByLabel(wrapper, label).get('input.arco-input-number-stub')
}
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
}
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
describe('KanbanCreateForm', () => {
@@ -87,9 +176,9 @@ describe('KanbanCreateForm', () => {
})
it('validates required title before submit', async () => {
const wrapper = mount(KanbanCreateForm)
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
await wrapper.findAll('.n-button-stub')[1].trigger('click')
await buttonByText(wrapper, 'common.create').trigger('click')
expect(mockMessage.warning).toHaveBeenCalledWith('kanban.form.titleRequired')
expect(mockCreateTask).not.toHaveBeenCalled()
@@ -97,15 +186,13 @@ describe('KanbanCreateForm', () => {
it('submits trimmed values and emits created/close', async () => {
mockCreateTask.mockResolvedValue({ id: 'task-1' })
const wrapper = mount(KanbanCreateForm)
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
const inputs = wrapper.findAll('.n-input-stub')
await inputs[0].setValue(' Ship kanban ')
await inputs[1].setValue(' write tests ')
const selects = wrapper.findAll('.n-select-stub')
await selects[0].setValue('alice')
await selects[1].setValue('3')
await wrapper.findAll('.n-button-stub')[1].trigger('click')
await fieldByLabel(wrapper, 'kanban.form.title').get('input').setValue(' Ship kanban ')
await fieldByLabel(wrapper, 'kanban.form.body').get('textarea').setValue(' write tests ')
await selectByLabel(wrapper, 'kanban.form.assignee').setValue('alice')
await selectByLabel(wrapper, 'kanban.form.priority').setValue('3')
await buttonByText(wrapper, 'common.create').trigger('click')
await flushPromises()
expect(mockCreateTask).toHaveBeenCalledWith({
@@ -121,28 +208,27 @@ describe('KanbanCreateForm', () => {
it('submits advanced dispatcher options', async () => {
mockCreateTask.mockResolvedValue({ id: 'task-1' })
const wrapper = mount(KanbanCreateForm)
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
await flushPromises()
const inputs = () => wrapper.findAll('.n-input-stub')
const selects = wrapper.findAll('.n-select-stub')
await inputs()[0].setValue('Build parity')
await inputs()[1].setValue('cover cli create flags')
await selects[2].setValue('worktree')
await fieldByLabel(wrapper, 'kanban.form.title').get('input').setValue('Build parity')
await fieldByLabel(wrapper, 'kanban.form.body').get('textarea').setValue('cover cli create flags')
await selectByLabel(wrapper, 'kanban.form.workspace').setValue('worktree')
await flushPromises()
await inputs()[2].setValue('ops')
await inputs()[3].setValue('/repo')
await inputs()[4].setValue('kanban-ui')
await selects[3].setValue(['planner', 'reviewer'])
await inputs()[5].setValue('2h')
await wrapper.findAll('.n-input-number-stub')[0].setValue('3')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await fieldByLabel(wrapper, 'kanban.form.tenant').get('input').setValue('ops')
await fieldByLabel(wrapper, 'kanban.form.workspacePath').get('input').setValue('/repo')
await fieldByLabel(wrapper, 'kanban.form.branch').get('input').setValue('kanban-ui')
await selectByLabel(wrapper, 'kanban.form.skills').setValue(['planner', 'reviewer'])
await fieldByLabel(wrapper, 'kanban.form.maxRuntime').get('input').setValue('2h')
await inputNumberByLabel(wrapper, 'kanban.form.maxRetries').setValue('3')
// triage and goalMode are the two checkboxes in the advanced row.
const checkboxes = wrapper.findAll('.advanced-row input[type="checkbox"]')
await checkboxes[0].setValue(true)
await checkboxes[1].setValue(true)
await flushPromises()
await wrapper.findAll('.n-input-number-stub')[1].setValue('12')
await inputNumberByLabel(wrapper, 'kanban.form.goalMaxTurns').setValue('12')
await wrapper.findAll('.n-button-stub')[1].trigger('click')
await buttonByText(wrapper, 'common.create').trigger('click')
await flushPromises()
expect(mockCreateTask).toHaveBeenCalledWith({
@@ -161,7 +247,7 @@ describe('KanbanCreateForm', () => {
})
it('uses compact profile names for assignee options', () => {
const wrapper = mount(KanbanCreateForm)
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
expect(wrapper.text()).toContain('alice')
expect(wrapper.text()).not.toContain('default')