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
+129 -162
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { defineComponent, h, inject, nextTick, provide, ref, useAttrs } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import ArcoVue from '@arco-design/web-vue'
const STT_STORAGE_KEY = 'hermes-stt-settings-v1'
@@ -207,168 +208,132 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', () => ({
vi.mock('@/composables/useMessage', () => ({
useMessage: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
NCard: defineComponent({
name: 'NCard',
inheritAttrs: false,
template: '<section v-bind="$attrs"><header><slot name="header" /></header><main><slot /></main><footer><slot name="action" /></footer></section>',
}),
NTag: defineComponent({
name: 'NTag',
inheritAttrs: false,
template: '<span v-bind="$attrs"><slot /></span>',
}),
NTooltip: defineComponent({
name: 'NTooltip',
inheritAttrs: false,
template: '<span><slot name="trigger" /><slot /></span>',
}),
NPopconfirm: defineComponent({
name: 'NPopconfirm',
inheritAttrs: false,
emits: ['positive-click'],
template: '<span><span @click="$emit(\'positive-click\')"><slot name="trigger" /></span><slot /></span>',
}),
NDropdown: defineComponent({
name: 'NDropdown',
inheritAttrs: false,
props: { options: { type: Array as () => Array<{ label: string; key: string; disabled?: boolean }>, default: () => [] } },
emits: ['select'],
template: '<span><slot /><button v-for="option in options" :key="option.key" type="button" :disabled="option.disabled" :data-testid="`dropdown-option-${option.key}`" @click="$emit(\'select\', option.key)">{{ option.label }}</button></span>',
}),
NModal: defineComponent({
name: 'NModal',
inheritAttrs: false,
props: { show: { type: Boolean, default: false } },
emits: ['update:show'],
template: '<div v-if="show" v-bind="$attrs"><slot /><slot name="footer" /></div>',
}),
NDrawer: defineComponent({
name: 'NDrawer',
inheritAttrs: false,
props: { show: { type: Boolean, default: false } },
emits: ['update:show'],
template: '<div v-if="show" v-bind="$attrs"><slot /></div>',
}),
NDrawerContent: defineComponent({
name: 'NDrawerContent',
inheritAttrs: false,
template: '<div v-bind="$attrs"><slot /><slot name="footer" /></div>',
}),
NForm: defineComponent({
name: 'NForm',
inheritAttrs: false,
template: '<form v-bind="$attrs"><slot /></form>',
}),
NFormItem: defineComponent({
name: 'NFormItem',
inheritAttrs: false,
props: { label: { type: String, default: '' } },
template: '<label v-bind="$attrs"><span>{{ label }}</span><slot /></label>',
}),
NSpace: defineComponent({
name: 'NSpace',
inheritAttrs: false,
template: '<div v-bind="$attrs"><slot /></div>',
}),
NGrid: defineComponent({
name: 'NGrid',
inheritAttrs: false,
template: '<div v-bind="$attrs"><slot /></div>',
}),
NGridItem: defineComponent({
name: 'NGridItem',
inheritAttrs: false,
template: '<div v-bind="$attrs"><slot /></div>',
}),
NSelect: defineComponent({
name: 'NSelect',
inheritAttrs: false,
props: {
value: { type: [String, Number], default: '' },
options: { type: Array as () => Array<{ label: string; value: string }>, default: () => [] },
size: { type: String, default: '' },
},
emits: ['update:value'],
template: `
<div class="mock-select">
<input v-bind="$attrs" :value="value" @input="$emit('update:value', $event.target.value)" />
<span v-for="option in options" :key="option.value" class="mock-select-option">{{ option.label }}</span>
</div>
`,
}),
NInput: defineComponent({
name: 'NInput',
inheritAttrs: false,
props: {
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
type: { type: String, default: 'text' },
rows: { type: Number, default: 2 },
size: { type: String, default: '' },
},
emits: ['update:value'],
template: `
<textarea
v-if="type === 'textarea'"
v-bind="$attrs"
:rows="rows"
:placeholder="placeholder"
:value="value"
@input="$emit('update:value', $event.target.value)"
/>
<input
v-else
v-bind="$attrs"
:type="type"
:placeholder="placeholder"
:value="value"
@input="$emit('update:value', $event.target.value)"
/>
`,
}),
NButton: defineComponent({
name: 'NButton',
inheritAttrs: false,
props: {
size: { type: String, default: '' },
type: { type: String, default: '' },
loading: { type: Boolean, default: false },
},
emits: ['click'],
template: '<button type="button" v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
}),
NSlider: defineComponent({
name: 'NSlider',
inheritAttrs: false,
props: {
value: { type: Number, default: 0 },
min: { type: Number, default: 0 },
max: { type: Number, default: 100 },
step: { type: Number, default: 1 },
size: { type: String, default: '' },
},
emits: ['update:value'],
template: `
<input
v-bind="$attrs"
type="range"
:min="min"
:max="max"
:step="step"
:value="value"
@input="$emit('update:value', Number($event.target.value))"
/>
`,
}),
}))
/**
* Arco overlays (modal / drawer / dropdown) render into a teleported popup, so
* they are stubbed to render inline. Stubs are keyed by Arco's *internal*
* component name — not the `a-*` template tag — because Arco is registered
* globally via `app.use(ArcoVue)`.
*/
const AModalStub = defineComponent({
name: 'Modal',
inheritAttrs: false,
props: { visible: { type: Boolean, default: false } },
emits: ['cancel', 'ok', 'update:visible'],
template: '<div v-if="visible" v-bind="$attrs" class="arco-modal-stub"><slot /><slot name="footer" /></div>',
})
const ADrawerStub = defineComponent({
name: 'Drawer',
inheritAttrs: false,
props: { visible: { type: Boolean, default: false } },
emits: ['cancel', 'update:visible'],
template: '<div v-if="visible" v-bind="$attrs" class="arco-drawer-stub"><slot /></div>',
})
const DOPTION_SELECT = Symbol('arco-doption-select')
const ADoptionStub = defineComponent({
name: 'Doption',
inheritAttrs: false,
props: { value: { type: [String, Number], default: '' }, disabled: { type: Boolean, default: false } },
setup(props, { slots }) {
const attrs = useAttrs()
const select = inject(DOPTION_SELECT, () => {})
return () => h('button', {
...attrs,
type: 'button',
class: 'arco-doption-stub',
disabled: props.disabled,
'data-value': props.value,
onClick: () => select(props.value),
}, slots.default?.())
},
})
const ADropdownStub = defineComponent({
name: 'Dropdown',
inheritAttrs: false,
emits: ['select'],
setup(_props, { slots, emit }) {
provide(DOPTION_SELECT, (value: unknown) => emit('select', value))
return () => h('div', { class: 'arco-dropdown-stub' }, [slots.default?.(), slots.content?.()])
},
})
const ASelectStub = defineComponent({
name: 'Select',
inheritAttrs: false,
props: {
modelValue: { type: [String, Number, Boolean, Object, Array], default: '' },
options: { type: Array as () => Array<{ label: string; value: unknown }>, default: () => [] },
placeholder: { type: String, default: '' },
},
emits: ['update:modelValue', 'change'],
template: `
<div class="arco-select-stub">
<input
class="arco-select-input"
:data-testid="$attrs['data-testid']"
:placeholder="placeholder"
:value="modelValue"
@change="onChange"
/>
<span v-for="option in options" :key="option.value" class="arco-select-option">{{ option.label }}</span>
</div>
`,
methods: {
onChange(event: Event) {
const raw = (event.target as HTMLInputElement).value
// Restore the option's declared value type so numeric ids stay numeric.
const typed = this.options.find(option => String(option.value) === raw)?.value ?? raw
this.$emit('update:modelValue', typed)
this.$emit('change', typed)
},
},
})
const arcoGlobal = {
plugins: [ArcoVue],
stubs: {
Modal: AModalStub,
Drawer: ADrawerStub,
Dropdown: ADropdownStub,
Doption: ADoptionStub,
Select: ASelectStub,
},
}
/**
* Arco's a-input / a-input-password wrap the real control in a
* `.arco-input-wrapper` span, so field test ids land on the span. Tests keep
* asserting against the same test ids and reach the control through the wrapper.
*/
function field(wrapper: any, testid: string) {
return wrapper.get(`[data-testid="${testid}"]`)
}
/** The settable control behind a field test id (the element itself for stubs). */
function fieldInput(wrapper: any, testid: string) {
const node = field(wrapper, testid)
return node.element.tagName === 'INPUT' ? node : node.get('input')
}
/** Menu entries inside an a-dropdown are located by their rendered label. */
function dropdownOptionByText(wrapper: any, text: string) {
const option = wrapper.findAll('.arco-doption-stub').find((node: any) => node.text().trim() === text)
if (!option) throw new Error(`dropdown option not found: ${text}`)
return option
}
vi.mock('@/api/hermes/stt-settings', () => ({
fetchSttSettings: mockFetchSttSettings,
saveSttSettings: mockSaveSttSettings,
@@ -759,7 +724,9 @@ describe('VoiceSettings STT UI', () => {
const VoiceSettings = await importVoiceSettingsComponent()
return mount(VoiceSettings, {
global: {
...arcoGlobal,
stubs: {
...arcoGlobal.stubs,
SettingRow: defineComponent({
name: 'SettingRow',
props: {
@@ -966,21 +933,21 @@ describe('VoiceSettings STT UI', () => {
await wrapper.findAll('button').find(button => button.text().includes('Add TTS API'))!.trigger('click')
await flushPromises()
await wrapper.get('[data-testid="voice-provider-select"]').setValue('tts-custom')
await fieldInput(wrapper, 'voice-provider-select').setValue('tts-custom')
await flushPromises()
expect(wrapper.text()).toContain('API compatibility')
expect(wrapper.text()).toContain('Connection')
expect(wrapper.text()).toContain('Model')
await wrapper.get('[data-testid="voice-provider-base-url"]').setValue('https://custom.example/v1/')
await wrapper.get('[data-testid="voice-provider-api-key"]').setValue('raw-custom-secret')
await wrapper.get('[data-testid="voice-provider-model"]').setValue('my-manual-model')
await fieldInput(wrapper, 'voice-provider-base-url').setValue('https://custom.example/v1/')
await fieldInput(wrapper, 'voice-provider-api-key').setValue('raw-custom-secret')
await fieldInput(wrapper, 'voice-provider-model').setValue('my-manual-model')
await flushPromises()
expect(mockProbeVoiceProvider).not.toHaveBeenCalled()
await wrapper.get('[data-testid="voice-provider-probe"]').trigger('click')
await field(wrapper, 'voice-provider-probe').trigger('click')
await flushPromises()
await nextTick()
@@ -993,7 +960,7 @@ describe('VoiceSettings STT UI', () => {
apiKey: 'raw-custom-secret',
}))
expect(wrapper.text()).toContain('Recommended: tts-1-hd')
expect((wrapper.get('[data-testid="voice-provider-model"]').element as HTMLInputElement).value).toBe('my-manual-model')
expect((fieldInput(wrapper, 'voice-provider-model').element as HTMLInputElement).value).toBe('my-manual-model')
await wrapper.findAll('button').find(button => button.text().includes('common.add'))!.trigger('click')
await flushPromises()
@@ -1021,7 +988,7 @@ describe('VoiceSettings STT UI', () => {
await flushPromises()
expect(wrapper.text()).toContain('Stored')
await wrapper.get('[data-testid="dropdown-option-remove"]').trigger('click')
await dropdownOptionByText(wrapper, 'Remove').trigger('click')
await flushPromises()
expect(mockDeleteTtsProvider).toHaveBeenCalledWith('mimo')