feat(demo): 添加 Naive UI 组件表单演示功能
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Has been cancelled
CI/CD Pipeline / playwright (push) Has been cancelled

This commit is contained in:
严浩
2025-10-31 13:40:23 +08:00
parent b3fbfe2d9d
commit 48eb653f1a
7 changed files with 228 additions and 6 deletions

2
auto-imports.d.ts vendored
View File

@@ -266,6 +266,7 @@ declare global {
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useSafeNForm: typeof import('./src/utils/use-safe-n-form-auto-imports')['useSafeNForm']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -608,6 +609,7 @@ declare module 'vue' {
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useSafeNForm: UnwrapRef<typeof import('./src/utils/use-safe-n-form-auto-imports')['useSafeNForm']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>

View File

@@ -60,6 +60,7 @@
"@unhead/vue": "^2.0.14",
"@vueuse/core": "^13.9.0",
"highlight.js": "^11.11.1",
"lodash-es": "^4.17.21",
"naive-ui": "^2.43.1",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
@@ -89,6 +90,7 @@
"@tsconfig/node22": "^22.0.2",
"@types/html-minifier-terser": "^7.0.2",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.18.1",
"@vant/auto-import-resolver": "^1.3.0",
"@vitejs/plugin-vue": "^6.0.1",
@@ -124,6 +126,7 @@
"stylelint-define-config": "^16.24.0",
"svgo": "^4.0.0",
"tinyglobby": "^0.2.15",
"type-fest": "^5.1.0",
"typescript": "~5.9.2",
"unocss": "^66.5.1",
"unocss-preset-animations": "^1.2.1",

23
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
lodash-es:
specifier: ^4.17.21
version: 4.17.21
naive-ui:
specifier: ^2.43.1
version: 2.43.1(vue@3.5.22(typescript@5.9.2))
@@ -120,6 +123,9 @@ importers:
'@types/jsdom':
specifier: ^27.0.0
version: 27.0.0
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/node':
specifier: ^22.18.1
version: 22.18.11
@@ -225,6 +231,9 @@ importers:
tinyglobby:
specifier: ^0.2.15
version: 0.2.15
type-fest:
specifier: ^5.1.0
version: 5.1.0
typescript:
specifier: ~5.9.2
version: 5.9.2
@@ -5167,6 +5176,10 @@ packages:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
terser@5.44.0:
resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==}
engines: {node: '>=10'}
@@ -5259,6 +5272,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
type-fest@5.1.0:
resolution: {integrity: sha512-wQ531tuWvB6oK+pchHIu5lHe5f5wpSCqB8Kf4dWQRbOYc9HTge7JL0G4Qd44bh6QuJCccIzL3bugb8GI0MwHrg==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -11011,6 +11028,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
tagged-tag@1.0.0: {}
terser@5.44.0:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -11088,6 +11107,10 @@ snapshots:
type-fest@0.20.2: {}
type-fest@5.1.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { useDialog, useMessage, useModal } from 'naive-ui';
import type { MessageType } from 'naive-ui';
import { useDialog, useMessage } from 'naive-ui';
import UseSafeNForm from './use-safe-n-form.vue';
definePage({ meta: {} });
const message = useMessage();
const dialog = useDialog();
const modal = useModal();
const messageTypes = ['info', 'success', 'warning', 'error', 'loading'] satisfies MessageType[];
const dialogTypes = ['info', 'success', 'warning', 'error'] as const;
@@ -38,11 +38,10 @@ const openDialog = (type: (typeof dialogTypes)[number]) => {
};
const openModal = () => {
modal.create({
window.$nModal!.create({
title: '命令式 Modal 示例',
content: '这是一个命令式 API 创建的 Modal 示例,使用 preset="dialog"。',
preset: 'dialog',
maskClosable: false,
onPositiveClick: () => {
message.success('点击了确定');
},
@@ -62,7 +61,9 @@ const openModal = () => {
<NAlert title="信息" type="info" :bordered="false">
演示 Naive UI 各种组件的使用方法和功能特性
</NAlert>
<n-card title="SafeNForm" mt-4>
<UseSafeNForm />
</n-card>
<NCard title="Message 消息" class="mt-4">
<NSpace>
<NButton

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
const { formValue, SafeNForm, SafeNFormItem, formRef } = useSafeNForm({
initialFormValue: {
/* ⚠️:
如果没使用`SafeNFormItem`
这里`user`对象没有手动初始化的话,将会报错:
`can't access property "name", $setup.formValue.user is undefined`
*/
user: {
name: '',
age: 0,
},
phone: '',
},
});
function handleValidateClick() {
formRef.value?.validate((errors) => {
if (!errors) {
window.$nMessage!.success('Valid');
} else {
console.log(errors);
window.$nMessage!.error('Invalid');
}
});
}
</script>
<template>
<div border>
<pre>formValue: {{ JSON.stringify(formValue, null, 2) }}</pre>
</div>
<SafeNForm inline label-placement="left" label-width="auto" mt-4>
<n-form-item
label="姓名"
path="user.name"
:rule="{ required: true, message: '请输入姓名', trigger: ['input'] }"
>
<n-input v-model:value="formValue.user.name" placeholder="输入姓名" />
</n-form-item>
<SafeNFormItem
#default="{ value, setValue }"
:rule="{ required: true, message: '请输入姓名', trigger: ['input'] }"
label="姓名"
path="user.name"
>
<NInput :value="value" placeholder="SafeNFormItem" @update:value="setValue" />
</SafeNFormItem>
<n-form-item
label="电话号码"
path="phone"
:rule="{ required: true, message: '请输入电话号码', trigger: ['blur'] }"
>
<n-input v-model:value="formValue.phone" placeholder="电话号码" />
</n-form-item>
<SafeNFormItem
label="电话号码"
path="phone"
:rule="{ required: true, message: '请输入电话号码', trigger: ['blur'] }"
>
<!-- 如果没有提供插槽会默认渲染一个`<NInput>` -->
</SafeNFormItem>
<n-form-item>
<n-button attr-type="button" @click="handleValidateClick"> 验证 </n-button>
</n-form-item>
</SafeNForm>
</template>

View File

@@ -0,0 +1,121 @@
/**
* https://www.naiveui.com/zh-CN/os-theme/components/form
*
* FIXME: `NForm` 和 `NFormItem` 的 slots 还没有实现。`NFormItemGi`组件。
*/
import { get, set } from 'lodash-es';
import type { FormInst, FormItemProps, FormProps } from 'naive-ui';
import { NForm, NFormItem, formItemProps, NInput, formProps } from 'naive-ui';
import type { Get, Paths } from 'type-fest';
import type { SlotsType } from 'vue';
import { Comment } from 'vue';
type UseSafeNFormOptions<FormValue> = {
initialFormValue?: FormValue;
};
export function useSafeNForm<T extends Record<string, any> = Record<string, unknown>>(
options: UseSafeNFormOptions<T> = {},
) {
const formRef = ref<FormInst | null>(null);
const formValue = ref<T>(structuredClone(toRaw(options.initialFormValue)) || ({} as T));
// 创建类型安全的 Form 组件
type SafeNFormProps = FormProps;
type SafeNFormSlots = SlotsType<{
default?: { count?: number };
}>;
const SafeNForm = defineComponent<SafeNFormProps, /* Emits */ [], /* EE */ never, SafeNFormSlots>(
(props, ctx) => {
return () => (
<NForm
{...props}
model={formValue.value}
ref={(inst) => {
formRef.value = inst as unknown as FormInst;
}}
>
{ctx.slots.default?.({})}
</NForm>
);
},
{
name: 'SafeNForm',
inheritAttrs: true,
props: formProps,
},
);
// <<<<<
// >>>>> 创建类型安全的 FormItem 组件
type SafeNFormItemProps<P extends Paths<T> & string> = FormItemProps & {
path: P;
};
type SafeNFormItemDefaultSlot<P extends Paths<T> & string> = {
value: Get<T, P>;
setValue: (val: Get<T, P>) => void;
};
const SafeNFormItemImpl = defineComponent<
SafeNFormItemProps<Paths<T> & string>,
/* Emits */ [],
/* EE */ never,
SlotsType<{ default: SafeNFormItemDefaultSlot<Paths<T> & string> }>
>(
(props, ctx) => {
return () => {
const value = get(formValue.value, props.path);
function setValue(val: typeof value) {
set(formValue.value, props.path, val);
}
const defaultSlotContent = ctx.slots.default?.({
value,
setValue,
});
// 如果没有提供默认 slot 内容,则渲染一个 NInput 作为默认输入组件
const renderDefaultNInput = defaultSlotContent?.some((v) => v.type !== Comment) ? null : (
<NInput value={value} onUpdate:value={setValue} />
);
return (
<NFormItem {...props} path={props.path}>
{defaultSlotContent}
{renderDefaultNInput}
</NFormItem>
);
};
},
{
name: 'SafeNFormItem',
inheritAttrs: false,
props: Object.keys(formItemProps) as unknown as [keyof FormItemProps],
},
);
// Expose a generic constructor so template literals narrow `path`.
type SafeNFormItemComponent = {
new <P extends Paths<T> & string>(
props: SafeNFormItemProps<P>,
): {
$props: SafeNFormItemProps<P>;
$slots: {
default?: (scope: SafeNFormItemDefaultSlot<P>) => VNode[];
};
};
};
const SafeNFormItem = SafeNFormItemImpl as SafeNFormItemComponent;
// <<<<<
return {
formValue,
SafeNForm,
SafeNFormItem,
formRef,
};
}

2
typed-router.d.ts vendored
View File

@@ -149,7 +149,7 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/demos/naive-ui-demo.page.vue': {
'src/pages/demos/naive-ui-demo/index.page.vue': {
routes:
| 'DemosNaiveUiDemo'
views: