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
@@ -2,6 +2,7 @@
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ArcoVue from '@arco-design/web-vue'
import ProviderCard from '@/components/hermes/models/ProviderCard.vue'
import type { AvailableModelGroup } from '@/api/hermes/system'
@@ -91,60 +92,6 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', () => ({
NButton: defineComponent({
name: 'NButton',
emits: ['click'],
setup(_, { slots, emit }) {
return () => h('button', { onClick: () => emit('click') }, slots.default?.())
},
}),
NCheckbox: defineComponent({
name: 'NCheckbox',
setup(_, { slots }) {
return () => h('label', slots.default?.())
},
}),
NCheckboxGroup: defineComponent({
name: 'NCheckboxGroup',
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
}),
NInput: defineComponent({
name: 'NInput',
setup() {
return () => h('input')
},
}),
NSelect: defineComponent({
name: 'NSelect',
props: {
value: { type: [String, Number], default: null },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
},
emits: ['update:value'],
setup(props, { emit }) {
return () => h('select', {
value: props.value || '',
onChange: (event: Event) => emit('update:value', (event.target as HTMLSelectElement).value),
}, [
h('option', { value: '' }, props.placeholder),
...(props.options as Array<{ label: string; value: string }>).map(option => h('option', { value: option.value }, option.label)),
])
},
}),
NModal: defineComponent({
name: 'NModal',
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
}),
useMessage: () => messageMock,
useDialog: () => dialogMock,
}))
// ProviderCard reads message/dialog through the Arco adapter layer, not
// naive-ui's composables, so the mock must target the adapter modules.
vi.mock('@/composables/useMessage', () => ({ useMessage: () => messageMock }))
@@ -155,6 +102,51 @@ vi.mock('@/stores/hermes/app', () => ({ useAppStore: () => appStoreMock }))
vi.mock('@/stores/hermes/chat', () => ({ useChatStore: () => chatStoreMock }))
vi.mock('@/api/hermes/copilot-auth', () => copilotAuthMock)
/**
* Arco's a-select renders a custom dropdown (no native <select>), and a-modal
* teleports to body in jsdom. Stub both so the card's model picker stays
* drivable through a plain <select> and modals render inline.
* Arco is registered globally, so stubs key off Arco's internal names.
*/
const ASelectStub = defineComponent({
name: 'Select',
props: {
modelValue: { type: [String, Number], default: null },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
disabled: Boolean,
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
return () =>
h(
'select',
{
class: 'arco-select-stub',
disabled: props.disabled,
value: props.modelValue ?? '',
onChange: (event: Event) => {
const value = (event.target as HTMLSelectElement).value
emit('update:modelValue', value)
emit('change', value)
},
},
[
h('option', { value: '' }, props.placeholder),
...(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>',
})
function provider(overrides: Partial<AvailableModelGroup>): AvailableModelGroup {
return {
provider: 'deepseek',
@@ -169,7 +161,13 @@ function provider(overrides: Partial<AvailableModelGroup>): AvailableModelGroup
function mountCard(group: AvailableModelGroup) {
modelsStoreMock.allProviders = [group]
return mount(ProviderCard, { props: { provider: group } })
return mount(ProviderCard, {
props: { provider: group },
global: {
plugins: [ArcoVue],
stubs: { Select: ASelectStub, Modal: AModalStub },
},
})
}
describe('ProviderCard built-in destructive action labels', () => {