aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
authorshu <nvcsadjkojf@outlook.com>2026-09-06 17:39:46 +0800
committershu <nvcsadjkojf@outlook.com>2026-09-06 17:39:46 +0800
commitfb14fbe0a3ecc801f010a7a429e30ddc57d9f2e4 (patch)
treed01f9bccc5beac11bbabaf07abfe367f8e9d6e8c /src
parent2f1057a9d9d51d0e657c1203d3781173e8efc611 (diff)
新增 Mantine 表单示例页面及输入校验演示
- 添加 /form 路由和表单示例菜单,通过路由懒加载拆分页面资源 - 展示文本、密码、数值、多行文本、单选、复选、开关、颜色和文件选择控件 - 使用 Cascader 展示省市区级联选择,支持搜索、清空和移动端路径列表 - 添加 MultiSelect 下拉多选与 TagsInput 自定义标签输入 - 接入 Mantine 表单状态、提交预览和重置,预览隐藏密码并仅显示文件元信息 - 添加必填标识、邮箱错误状态与提示,以及示例协议阅读和同意校验 - 将文件名换行样式封装为 CSS Module 验证:pnpm build、pnpm lint、git diff --check 均通过。
Diffstat (limited to 'src')
-rw-r--r--src/FormExample.module.css3
-rw-r--r--src/FormExample.tsx306
-rw-r--r--src/routes.ts8
3 files changed, 316 insertions, 1 deletions
diff --git a/src/FormExample.module.css b/src/FormExample.module.css
new file mode 100644
index 0000000..913f3f9
--- /dev/null
+++ b/src/FormExample.module.css
@@ -0,0 +1,3 @@
+.fileName {
+ overflow-wrap: anywhere;
+}
diff --git a/src/FormExample.tsx b/src/FormExample.tsx
new file mode 100644
index 0000000..7f38754
--- /dev/null
+++ b/src/FormExample.tsx
@@ -0,0 +1,306 @@
+import { useState } from 'react'
+import {
+ Alert,
+ Button,
+ Checkbox,
+ Cascader,
+ Code,
+ ColorInput,
+ FileInput,
+ Group,
+ MultiSelect,
+ NumberInput,
+ Paper,
+ PasswordInput,
+ Radio,
+ Select,
+ SimpleGrid,
+ Stack,
+ Switch,
+ TagsInput,
+ Text,
+ Textarea,
+ TextInput,
+ Title,
+ useMatches,
+} from '@mantine/core'
+import { isEmail, useForm } from '@mantine/form'
+import { UploadSimpleIcon } from '@phosphor-icons/react'
+import classes from './FormExample.module.css'
+
+// 仅用于演示联动,不是完整行政区划数据。
+const regions = [
+ {
+ name: '浙江省',
+ cities: [
+ { name: '杭州市', districts: ['西湖区', '拱墅区'] },
+ { name: '宁波市', districts: ['海曙区', '鄞州区'] },
+ ],
+ },
+ {
+ name: '江苏省',
+ cities: [
+ { name: '南京市', districts: ['鼓楼区', '玄武区'] },
+ { name: '苏州市', districts: ['姑苏区', '吴中区'] },
+ ],
+ },
+]
+
+const regionOptions = regions.map((province) => ({
+ value: province.name,
+ children: province.cities.map((city) => ({
+ value: city.name,
+ children: city.districts.map((district) => ({ value: district })),
+ })),
+}))
+
+type FormValues = {
+ name: string
+ email: string
+ role: string | null
+ region: string[] | null
+ contact: string
+ interests: string[]
+ technologies: string[]
+ tags: string[]
+ color: string
+ files: File[]
+ quantity: number | string
+ password: string
+ description: string
+ notifications: boolean
+ acceptedTerms: boolean
+}
+
+const exampleErrors = { email: '邮箱格式不正确,例如 name@example.com' }
+
+export default function FormExample() {
+ const [submitted, setSubmitted] = useState<string | null>(null)
+ const withColumns = useMatches({ base: false, sm: true })
+ const form = useForm<FormValues>({
+ mode: 'controlled',
+ validateInputOnBlur: true,
+ initialErrors: exampleErrors,
+ initialValues: {
+ name: '',
+ email: 'invalid-email',
+ role: null,
+ region: null,
+ contact: 'email',
+ interests: [],
+ technologies: [],
+ tags: [],
+ color: '#228be6',
+ files: [],
+ quantity: 1,
+ password: '',
+ description: '',
+ notifications: false,
+ acceptedTerms: false,
+ },
+ validate: {
+ name: (value) => value.trim() ? null : '请输入名称',
+ email: (value) => !value.trim() ? '请输入邮箱' : isEmail(exampleErrors.email)(value.trim()),
+ role: (value) => value ? null : '请选择角色',
+ quantity: (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 100
+ ? null : '请输入 0–100 之间的整数',
+ password: (value) => value && value.length < 8 ? '密码至少需要 8 位' : null,
+ color: (value) => /^#[\da-f]{6}$/i.test(value) ? null : '请输入有效的六位 HEX 颜色',
+ acceptedTerms: (value) => value ? null : '请先阅读并同意示例协议',
+ },
+ })
+
+ function submit(values: FormValues) {
+ setSubmitted(JSON.stringify({
+ ...values,
+ password: values.password ? '已填写(不展示密码)' : '未填写',
+ files: values.files.map((file) => ({
+ name: file.name,
+ size: file.size,
+ type: file.type || '未知类型',
+ })),
+ }, null, 2))
+ }
+
+ return (
+ <Stack gap="lg">
+ <div>
+ <Title order={1}>表单示例</Title>
+ <Text c="dimmed">体验常用表单控件。提交后在本页预览结果,数据和文件不会发送到服务器。</Text>
+ </div>
+
+ <Paper withBorder p="md">
+ <form
+ onSubmit={form.onSubmit(submit, () => setSubmitted(null))}
+ onReset={(event) => {
+ event.preventDefault()
+ form.reset()
+ form.setErrors(exampleErrors)
+ setSubmitted(null)
+ }}
+ noValidate
+ >
+ <Stack gap="lg">
+ <Text size="sm" c="dimmed">带 * 的项目为必填项;离开输入框或提交时会校验。邮箱预填了错误值,用于演示错误提示。</Text>
+ <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
+ <TextInput
+ label="文本输入框"
+ placeholder="请输入名称"
+ required
+ {...form.getInputProps('name')}
+ />
+ <TextInput
+ label="邮箱(错误提示示例)"
+ description="修改为有效邮箱后可正常提交"
+ placeholder="name@example.com"
+ type="email"
+ autoComplete="email"
+ required
+ {...form.getInputProps('email')}
+ />
+ <Select
+ label="选择框"
+ placeholder="请选择角色"
+ data={['开发者', '设计师', '产品经理']}
+ searchable
+ clearable
+ required
+ {...form.getInputProps('role')}
+ />
+ <NumberInput
+ label="数值输入框"
+ description="数量,范围 0–100"
+ min={0}
+ max={100}
+ allowDecimal={false}
+ {...form.getInputProps('quantity')}
+ />
+ <PasswordInput
+ label="密码输入框"
+ description="选填,填写时至少 8 位"
+ placeholder="请输入示例密码"
+ autoComplete="new-password"
+ {...form.getInputProps('password')}
+ />
+ </SimpleGrid>
+
+ <Cascader
+ label="级联选择框"
+ description="选择省、市、区,使用部分示例地区"
+ placeholder="请选择省 / 市 / 区"
+ data={regionOptions}
+ withColumns={withColumns}
+ searchable
+ clearable
+ closeOnSelect
+ clearButtonProps={{ 'aria-label': '清空地区' }}
+ nothingFoundMessage="没有匹配的地区"
+ {...form.getInputProps('region')}
+ />
+
+ <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
+ <MultiSelect
+ label="下拉多选框"
+ description="选择多个技术方向"
+ placeholder="请选择技术方向"
+ data={['React', 'TypeScript', 'Vite', 'Mantine', 'Node.js']}
+ searchable
+ clearable
+ clearButtonProps={{ 'aria-label': '清空技术方向' }}
+ nothingFoundMessage="没有匹配的选项"
+ {...form.getInputProps('technologies')}
+ />
+ <TagsInput
+ label="标签输入框"
+ description="输入后按 Enter 或逗号添加标签,也可选择推荐标签"
+ placeholder="输入或选择标签"
+ data={['学习', '工作', '个人项目', '开源']}
+ clearable
+ clearButtonProps={{ 'aria-label': '清空标签' }}
+ {...form.getInputProps('tags')}
+ />
+ <Radio.Group label="单选框" description="偏好的联系方式" {...form.getInputProps('contact')}>
+ <Group mt="xs">
+ <Radio value="email" label="邮件" />
+ <Radio value="phone" label="电话" />
+ <Radio value="message" label="站内消息" />
+ </Group>
+ </Radio.Group>
+ <Checkbox.Group label="多选框" description="感兴趣的方向" {...form.getInputProps('interests')}>
+ <Group mt="xs">
+ <Checkbox value="frontend" label="前端" />
+ <Checkbox value="backend" label="后端" />
+ <Checkbox value="design" label="设计" />
+ </Group>
+ </Checkbox.Group>
+ <ColorInput
+ label="颜色选择框"
+ format="hex"
+ swatches={['#228be6', '#40c057', '#fab005', '#fa5252', '#7950f2']}
+ {...form.getInputProps('color')}
+ />
+ <FileInput
+ label="文件上传框"
+ description="支持选择多个文件,仅展示文件信息"
+ placeholder="点击选择文件"
+ leftSection={<UploadSimpleIcon aria-hidden="true" />}
+ multiple
+ clearable
+ {...form.getInputProps('files')}
+ />
+ </SimpleGrid>
+
+ {form.values.files.length > 0 && (
+ <Stack gap="xs" aria-live="polite">
+ {form.values.files.map((file, index) => (
+ <Text key={`${file.name}-${index}`} size="sm" className={classes.fileName}>
+ {file.name} · {(file.size / 1024).toFixed(1)} KB
+ </Text>
+ ))}
+ </Stack>
+ )}
+
+ <Textarea
+ label="文本框"
+ description="多行文本,例如个人介绍或备注"
+ placeholder="请输入内容"
+ autosize
+ minRows={3}
+ maxRows={8}
+ {...form.getInputProps('description')}
+ />
+ <Switch
+ label="开关:接收消息通知"
+ {...form.getInputProps('notifications', { type: 'checkbox' })}
+ />
+ <Stack gap="xs">
+ <details>
+ <summary>查看示例协议</summary>
+ <Text size="sm" c="dimmed" py="xs">
+ 本页面仅用于演示表单交互。提交内容仅在当前页面预览,文件不会上传。
+ 勾选同意用于演示提交前的校验流程,不代表订购或开通服务。
+ </Text>
+ </details>
+ <Checkbox
+ label="我已阅读并同意示例协议"
+ required
+ {...form.getInputProps('acceptedTerms', { type: 'checkbox', withError: true })}
+ />
+ </Stack>
+ <Group>
+ <Button type="submit">提交预览</Button>
+ <Button type="reset" variant="default">重置</Button>
+ </Group>
+ </Stack>
+ </form>
+ </Paper>
+
+ {submitted !== null && (
+ <Stack gap="xs">
+ <Alert title="提交成功" color="green" role="status">以下是本次提交的示例数据。</Alert>
+ <Code block>{submitted}</Code>
+ </Stack>
+ )}
+ </Stack>
+ )
+}
diff --git a/src/routes.ts b/src/routes.ts
index 83f97b6..f0a0ef8 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -1,4 +1,4 @@
-import { HouseIcon, InfoIcon, UserIcon } from '@phosphor-icons/react'
+import { ClipboardTextIcon, HouseIcon, InfoIcon, UserIcon } from '@phosphor-icons/react'
import type { Icon } from '@phosphor-icons/react'
import type { RouteObject } from 'react-router'
import App from './App.tsx'
@@ -18,6 +18,12 @@ export const pageRoutes = [
handle: { title: '首页', icon: HouseIcon },
},
{
+ id: 'form',
+ path: '/form',
+ lazy: async () => ({ Component: (await import('./FormExample.tsx')).default }),
+ handle: { title: '表单示例', icon: ClipboardTextIcon },
+ },
+ {
id: 'about',
path: '/about',
handle: { title: '关于', icon: InfoIcon },