Appearance
总结版(先给结论)
- 不要过度收紧:全球姓名形态多样,白名单过窄会伤害用户体验。
- 推荐做法:最小必需限制 + 长度与内容检查 + 规范化处理 + 人类可读反馈。
- 允许字符集合(推荐):
Unicode 字母(\p{L}) + 组合音标(\p{M}) + 空格 + 连字符(-) + 撇号(' 与 ’) + 点号(.) + 圆括号( )。 - 禁止:数字、下划线、@/#、表情符号、控制/不可见字符(如零宽字符)、多空格/多标点连写、首尾标点/空格。
- 长度建议:
1 ~ 70个字素(grapheme),保证像“👨👩👧👦”等不会混入;但对极短名字(如“冯”“Ö”)要允许。 - 存储:保留原始输入(审计/回显),同时保存规范化副本用于检索/去重。
- 可选:后端做脚本混用检测(防钓鱼,如拉丁 + 西里尔混用),并记录语言脚本以供风控参考。
设计原则
- 国际化友好:姓名并非仅“字母+空格”,还会包含重音符号、连字符、撇号、复姓、括注(如
(Jr.))。 - 可访问性:允许用户粘贴内容,但清理不可见字符与多余空白。
- 可解释错误:错误提示明确指出允许的字符与修复建议。
- 安全性:阻断控制字符、Emoji、脚本混淆与钓鱼字符(例如混入相似形字母)。
推荐的两档校验策略
A 档(业务最常用,兼顾宽容与安全)
适用于大多数面向公众的注册/资料编辑表单
校验目标
- 允许:
\p{L}字母、\p{M}组合音标、空格、- . ' ’ ( ) - 禁止:数字、符号类(除上述)、控制/不可见字符、Emoji、行分隔符、段落分隔符
- 规则:不能以空格/标点开头或结尾;中间不允许连续标点/空格;必须至少包含一个字母
正则(整串匹配)
ts
// 复杂逻辑:多语言姓名“推荐版”正则(整串匹配)
const NAME_REGEX =
/^(?=.{1,70}$)(?=.*\p{L})(?!.*\p{C})(?!.*[\p{Zl}\p{Zp}])(?![ .'’()\-.])(?!.*[ .'’()\-.]{2})[\p{L}\p{M} .'’()\-.]+(?<![ .'’()\-.])$/u;说明(简要)
(?=.{1,70}$):长度 1~70(以代码点为粒度;如需更严谨用“字素段”计数,见后文)(?=.*\p{L}):至少出现一个字母(?!.*\p{C}):拒绝控制/不可见字符(含零宽字符、BOM 等)(?!.*[\p{Zl}\p{Zp}]):拒绝行/段落分隔符- 禁止首尾标点/空格 & 禁止连续标点/空格
- 允许集合:
[\p{L}\p{M} .'’()\-.]
如果你要严格只允许普通空格(U+0020),而不接受其它空白(不含 Tab、全角空格等),将上式中的空格保留为字面空格即可(不要用
\s或\p{Zs})。
B 档(更宽松,用于“导入/录入”场景,之后人工复核)
适合内部工具、批量导入、历史数据迁移等,尽量不拦截,主要做清洗
- 放宽首尾与连续标点限制,但仍禁止 \p{C} 控制字符、Emoji、数字等明显异常。
- 表单不拦截,保存后扫描并给出“待人工确认”的标记。
进阶:字素长度与规范化建议
1) 使用 Intl.Segmenter 做**字素(grapheme)**长度限制
ts
// 复杂逻辑:以“字素”粒度限制长度,处理组合音标/合字更准确
function graphemeLength(str: string) {
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
let n = 0;
for (const _ of seg.segment(str)) n++;
return n;
}
function withinNameLength(str: string, min = 1, max = 70) {
const len = graphemeLength(str);
return len >= min && len <= max;
}2) 统一规范化(建议 NFKC)+ 清理
ts
// 复杂逻辑:规范化 + 清理(存储前/比对前)
// - 统一全角/半角形态(NFKC)
// - 统一撇号为 ASCII '
// - 合并连续空格/标点为单个
// - 去掉首尾空格/标点
function normalizeName(raw: string) {
let s = raw.normalize("NFKC");
s = s.replace(/[’ʻˈʹ`´]/g, "'"); // 各式撇号统一为 '
s = s.replace(/\s+/g, " "); // 多空格合并
s = s.replace(/[ .\-']{2,}/g, (m) => m[0]); // 连续标点压缩为 1 个
s = s.replace(/^[ .\-']+|[ .\-']+$/g, ""); // 去首尾标点/空格
// 移除控制/不可见字符(含零宽)
s = s.replace(/\p{C}/gu, "");
return s;
}存储策略:原文与规范化结果都存:
name_raw:原始输入(可回显、审计)
name_norm:规范化副本(搜索/唯一性判断/排序)
- 唯一性用
name_norm+ 国家/地区/生日等组合更靠谱。
与 Ant Design Vue 的集成(仅提供必要片段)
片段一:新增工具函数与正则
ts
// 复杂逻辑:姓名推荐正则(A档)
const NAME_REGEX =
/^(?=.{1,70}$)(?=.*\p{L})(?!.*\p{C})(?!.*[\p{Zl}\p{Zp}])(?![ .'’()\-.])(?!.*[ .'’()\-.]{2})[\p{L}\p{M} .'’()\-.]+(?<![ .'’()\-.])$/u;
// 复杂逻辑:规范化工具
function normalizeName(raw: string) {
let s = raw.normalize("NFKC");
s = s
.replace(/[’ʻˈʹ`´]/g, "'")
.replace(/\s+/g, " ")
.replace(/[ .\-']{2,}/g, (m) => m[0])
.replace(/^[ .\-']+|[ .\-']+$/g, "")
.replace(/\p{C}/gu, "");
return s;
}片段二:在提交前做规范化(不改变你的其它逻辑)
ts
// 复杂逻辑:提交前对姓/名进行规范化(个人账户)
async function handleRegister() {
try {
if (!formData.agreed) {
message.error(t("user.register.form.agree-terms"));
return;
}
if (isPersonal.value) {
formData.lastname = normalizeName(formData.lastname);
formData.firstname = normalizeName(formData.firstname);
}
await userStore.register(buildPayload());
message.success(t("user.register.form.register-success"));
if (router.currentRoute.value?.query?.redirect) {
router.push(
decodeURIComponent(router.currentRoute.value.query.redirect as string)
);
} else {
router.push("/");
}
} catch (err) {}
}片段三:为姓/名追加 pattern 规则(你的 computedRules 个人分支内)
ts
// 复杂逻辑:姓/名仅允许多语言字母 + 空格/().-'
lastname: [
req(t('user.register.form.lastname-required')),
{ pattern: NAME_REGEX, message: '姓:仅允许字母与 空格 ( ) . - \',且不得以标点开头/结尾', trigger: 'blur' },
],
firstname: [
req(t('user.register.form.firstname-required')),
{ pattern: NAME_REGEX, message: '名:仅允许字母与 空格 ( ) . - \',且不得以标点开头/结尾', trigger: 'blur' },
],常见陷阱与应对
- Emoji/符号混入:用户复制来源不洁;用
\p{C}排除控制字符,另外通过允许集合从源头排除\p{S}符号类。 - 脚本混淆:
а(西里尔) 与a(拉丁) 混用易钓鱼;重要业务可在后端做脚本一致性检查(如“主要脚本比例 ≥ 80%”)。 - 零宽字符:ZWJ/ZWNJ 常用于印度文字排版,但姓名中通常不要求;若确有需求,放行特定范围并记录风控。
- 极短/极长:允许一字名;上限不宜太短(建议 70 字素)。
- 大小写/重音:显示保持用户意图;检索用规范化(NFKC + 去重音可选,视需求)。
何时放宽与何时从严
- 注册/资料编辑(面向 C 端):用 A 档。
- 批量导入/历史迁移:用 B 档,之后人工复核。
- 高风险场景(KYC/合规):在 A 档基础上增加脚本一致性、脏词/辱骂筛查、与证件 OCR 的模糊比对。
可替代与增强
- 库支持:若可用 ICU/Unorm 等后端库,优先在后端实现统一规范化与脚本检测。
- 多语言提示:按界面语言显示本地化错误信息;错误文案写清“允许的字符集合”。
- 可视化纠错:输入时即时轻提示并给出“自动修复(规范化)”的预览。
结语
姓名是文化与书写系统的交汇点。最佳实践的关键不在“最严的正则”,而在合理的允许集合 + 规范化 + 友好提示 + 风控兜底。上面的 A 档规则能在全球化与安全之间取得良好平衡,建议作为你的默认实现。
title: 注册组件(已支持姓名中点“·”与公司名符号 & / ,) date: 2025-09-22 tags: [Vue3, Ant Design Vue, 表单校验, i18n]
已按你的要求:姓名允许中点“·”;公司名增加单独规则(放行
& / ,等),并在提交前做对应规范化。
vue
<template>
<!-- 桌面端 -->
<div
v-if="!isMobile"
class="w-full h-full mx-[40px] lg:mx-0 rounded-[14px] p-10 flex flex-col gap-4 glass gradient-border user-auth overflow-y-auto scrollbar-none"
>
<slot name="header" />
<HTabsHeader v-model:activeKey="activeKey" :items="items" />
<a-form
:model="formData"
:rules="computedRules"
:required-mark="false"
layout="vertical"
class="w-full overflow-y-auto scrollbar-none"
style="margin: 24px 0"
>
<template v-if="isPersonal">
<a-form-item
:label="t('user.register.form.firstname')"
name="firstname"
required
>
<a-input class="form-control" v-model:value="formData.firstname" />
</a-form-item>
<a-form-item
:label="t('user.register.form.lastname')"
name="lastname"
required
>
<a-input class="form-control" v-model:value="formData.lastname" />
</a-form-item>
</template>
<template v-else>
<a-form-item
:label="t('user.register.form.company-name')"
name="company_name"
required
>
<a-input class="form-control" v-model:value="formData.company_name" />
</a-form-item>
</template>
<a-form-item
:label="
isPersonal
? t('user.register.form.country-personal')
: t('user.register.form.country-corporate')
"
name="country"
required
>
<CountrySelector
class="register-country-selector"
size="xl"
container-class="border border-[#1f2b49] hover:border-[#b68756]"
v-model:countryEnName="formData.country"
toggle-class="text-[#ddd]"
/>
</a-form-item>
<a-form-item :label="t('user.register.form.phone')" name="phone">
<HPhoneInput
wrap-class="w-full h-[48px] rounded-[10px] border border-[#1F2B49] px-3 py-[13px] hover:border-[#B68756]"
toggle-class="text-[#DDD]"
input-class="text-[#ddd]"
size="xl"
v-model="formData.phone"
v-model:phoneCode="formData.area"
:button-text="t('user.register.form.send-verification-code')"
/>
</a-form-item>
<a-form-item :label="t('user.register.form.email')" name="email" required>
<a-input
class="form-control"
autocomplete="register-email"
type="email"
v-model:value="formData.email"
/>
</a-form-item>
<a-form-item
:label="t('user.register.form.email-verification-code')"
name="code"
required
>
<MVerifyCodeInput
v-model:value="formData.code"
:verify-setting="customVerifyType"
input-class="!text-[#ddd]"
/>
</a-form-item>
<a-form-item
:label="t('user.register.form.password')"
name="password"
required
>
<MPasswordInput v-model:value="formData.password" />
</a-form-item>
<a-form-item
v-if="!props.hideInviteCode"
:label="t('user.register.form.invitation-code')"
name="invitation_code"
>
<a-input
class="form-control"
v-model:value="formData.invitation_code"
/>
</a-form-item>
<a-form-item>
<div class="mb-6 flex items-start gap-2">
<input
class="mt-1"
type="checkbox"
id="agreement"
v-model="formData.agreed"
/>
<i18n-t
keypath="user.register.form.disclaimer"
tag="p"
class="text-[12px] leading-[20px] font-normal text-[#ddd]"
>
<RouterLink to="/agreements/customer" class="text-primary-500">
{{ t("user.register.form.customer-agreement") }}
</RouterLink>
</i18n-t>
</div>
</a-form-item>
</a-form>
<a-button type="primary" size="large" block @click="handleRegister">
{{ t("user.register.form.submit") }}
</a-button>
<div class="flex items-center justify-center mt-6 ink-14 font-normal">
<span
class="text-primary-500 cursor-pointer"
@click="$emit('switch-to-login')"
>
{{ t("user.register.form.go-to-login") }}
</span>
</div>
</div>
<!-- 移动端 -->
<div
v-else
class="flex flex-col gap-4 w-full h-full p-10 glass user-auth overflow-y-auto hide-scrollbar"
>
<slot name="header" />
<HTabsHeader v-model:activeKey="activeKey" :items="items" />
<a-form
:model="formData"
:rules="computedRules"
:required-mark="false"
layout="vertical"
class="w-full overflow-y-auto scrollbar-none"
style="margin: 24px 0"
>
<template v-if="isPersonal">
<a-form-item
:label="t('user.register.form.lastname')"
name="lastname"
required
>
<a-input class="form-control" v-model:value="formData.lastname" />
</a-form-item>
<a-form-item
:label="t('user.register.form.firstname')"
name="firstname"
required
>
<a-input class="form-control" v-model:value="formData.firstname" />
</a-form-item>
</template>
<template v-else>
<a-form-item
:label="t('user.register.form.company-name')"
name="company_name"
required
>
<a-input class="form-control" v-model:value="formData.company_name" />
</a-form-item>
</template>
<a-form-item
:label="
isPersonal
? t('user.register.form.country-personal')
: t('user.register.form.country-corporate')
"
name="country"
required
>
<CountrySelector
class="register-country-selector"
container-class="border border-[#1f2b49] hover:border-[#b68756]"
size="xl"
v-model:countryEnName="formData.country"
toggle-class="text-[#ddd]"
/>
</a-form-item>
<a-form-item :label="t('user.register.form.phone')" name="phone">
<HPhoneInput
wrap-class="w-full h-[48px] rounded-[10px] border border-[#1F2B49] px-3 py-[13px] hover:border-[#B68756]"
toggle-class="text-[#DDD]"
input-class="text-[#ddd]"
size="xl"
v-model="formData.phone"
v-model:phoneCode="formData.area"
:button-text="t('user.register.form.send-verification-code')"
/>
</a-form-item>
<a-form-item :label="t('user.register.form.email')" name="email" required>
<a-input
class="form-control"
type="email"
v-model:value="formData.email"
/>
</a-form-item>
<a-form-item
v-if="isPersonal"
:label="t('user.register.form.email-verification-code')"
name="code"
required
>
<MVerifyCodeInput
v-model:value="formData.code"
:verify-setting="customVerifyType"
input-class="!text-[#ddd]"
/>
</a-form-item>
<a-form-item
:label="t('user.register.form.password')"
name="password"
required
>
<MPasswordInput v-model:value="formData.password" />
</a-form-item>
<a-form-item
v-if="!props.hideInviteCode"
:label="t('user.register.form.invitation-code')"
name="invitation_code"
>
<a-input
class="form-control"
v-model:value="formData.invitation_code"
/>
</a-form-item>
<a-form-item>
<div class="mb-6 flex items-start gap-2">
<input
class="mt-1"
type="checkbox"
id="agreement"
v-model="formData.agreed"
/>
<i18n-t
keypath="user.register.form.disclaimer"
tag="p"
class="text-[12px] leading-[20px] font-normal text-[#ddd]"
>
<RouterLink to="/agreements/customer" class="text-primary-500">
{{ t("user.register.form.customer-agreement") }}
</RouterLink>
</i18n-t>
</div>
</a-form-item>
</a-form>
<a-button type="primary" size="large" block @click="handleRegister">
{{ t("user.register.form.submit") }}
</a-button>
<div class="flex items-center justify-center mt-6 ink-14 font-normal">
<span
class="text-primary-500 cursor-pointer"
@click="$emit('switch-to-login')"
>
{{ t("user.register.form.go-to-login") }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, watch } from "vue";
import { useWindowSize } from "@vueuse/core";
import type { Rule } from "ant-design-vue/es/form";
import { message } from "ant-design-vue";
import { useI18n } from "vue-i18n";
import { useUserStore } from "@/store";
import {
HPhoneInput,
HTabsHeader,
MVerifyCodeInput,
MPasswordInput,
CountrySelector,
} from "@/components";
import { RouterLink, useRouter } from "vue-router";
import type {
RegisterPayload,
PersonalAccountRegisterPayload,
CorporateAccountRegisterPayload,
} from "@/api/auth/dto";
const router = useRouter();
const { t } = useI18n();
// 复杂逻辑:姓名“推荐版”正则(放行中点 U+00B7)
const NAME_REGEX =
/^(?=.{1,70}$)(?=.*\p{L})(?!.*\p{C})(?!.*[\p{Zl}\p{Zp}])(?![ .'’()\-.·])(?!.*[ .'’()\-.·]{2})[\p{L}\p{M} .'’()\-.·]+(?<![ .'’()\-.·])$/u;
// 复杂逻辑:公司名正则(更宽:放行 & / ,,可含数字;限制首尾与连续标点)
const COMPANY_NAME_REGEX =
/^(?=.{1,120}$)(?!.*\p{C})(?!.*[\p{Zl}\p{Zp}])(?![ .,'’()\/&\-·])(?!.*[ .,'’()\/&\-·]{2})[\p{L}\p{M}\p{N} .,'’()\/&\-·]+(?<![ .,'’()\/&\-·])$/u;
// 复杂逻辑:将姓名做 NFKC 规范化 + 清理(含全角→半角、压缩空格/标点、移除控制符)
function normalizeName(raw: string) {
let s = (raw ?? "").normalize("NFKC");
// 统一撇号与空格形态
s = s.replace(/[’ʻˈʹ`´]/g, "'").replace(/\u3000/g, " ");
// 常见“中点”变体统一为 U+00B7
s = s.replace(/・/g, "·");
// 压缩空白与标点、去首尾
s = s
.replace(/\s+/g, " ")
.replace(/[ .\-'\u00B7]{2,}/g, (m) => m[0])
.replace(/^[ .\-'\u00B7]+|[ .\-'\u00B7]+$/g, "");
// 移除控制/不可见字符
s = s.replace(/\p{C}/gu, "");
return s;
}
// 复杂逻辑:公司名规范化(额外统一中英标点 &/,等为 ASCII)
function normalizeCompanyName(raw: string) {
let s = (raw ?? "").normalize("NFKC");
s = s
.replace(/\u3000/g, " ")
.replace(/[’ʻˈʹ`´]/g, "'")
.replace(/,/g, ",")
.replace(///g, "/")
.replace(/&/g, "&")
.replace(/./g, ".")
.replace(/-/g, "-")
.replace(/(/g, "(")
.replace(/)/g, ")")
.replace(/・/g, "·");
// 压缩空白与标点(含 & / , 中点)
s = s
.replace(/\s+/g, " ")
.replace(/[ .,'\/&\-\u00B7]{2,}/g, (m) => m[0])
.replace(/^[ .,'\/&\-\u00B7]+|[ .,'\/&\-\u00B7]+$/g, "")
.replace(/\p{C}/gu, "");
return s;
}
// 复杂逻辑:姓/名自定义校验 —— 先规范化再判定,并回写表单值
const nameValidator: Rule["validator"] = async (_rule, value) => {
const normalized = normalizeName(value ?? "");
if (!normalized) throw new Error(t("user.register.form.name-empty"));
if (!NAME_REGEX.test(normalized))
throw new Error(t("user.register.form.name-pattern"));
return Promise.resolve();
};
// 复杂逻辑:公司名自定义校验 —— 允许 & / , 等,禁止首尾/连续标点
const companyNameValidator: Rule["validator"] = async (_rule, value) => {
const normalized = normalizeCompanyName(value ?? "");
if (!normalized)
throw new Error(t("user.register.form.company-name-required"));
if (!COMPANY_NAME_REGEX.test(normalized))
throw new Error(t("user.register.form.company-name-pattern"));
return Promise.resolve();
};
// 检测屏幕尺寸,判断是否为移动端
const { width } = useWindowSize();
const isMobile = computed(() => width.value < 768);
// props: 控制邀请码显示与预填
const props = withDefaults(
defineProps<{
hideInviteCode?: boolean;
presetInviteCode?: string | number | null;
recommendPreset?: string | number | null;
scopePreset?: string | number | null;
}>(),
{ hideInviteCode: false, presetInviteCode: null }
);
// tabs
const items = ref([
{ key: "personalAccount", label: t("user.register.form.personal-account") },
{ key: "corporateAccount", label: t("user.register.form.corporate-account") },
]);
type ActiveKey = "personalAccount" | "corporateAccount";
const activeKey = ref<ActiveKey>("personalAccount");
const isPersonal = computed(() => activeKey.value === "personalAccount");
// form & rules
const formData = reactive({
country: "",
area: undefined as string | undefined,
phone: "",
email: "",
password: "",
confirmPassword: "",
invitation_code: "",
agreed: false,
code: "",
lastname: "",
firstname: "",
company_name: "",
});
// 预设邀请码:支持组件先渲染后 props 变化。仅在表单值为空时写入,避免覆盖用户输入。
watch(
() => props.presetInviteCode,
(v) => {
if (v != null && v !== "" && !formData.invitation_code) {
formData.invitation_code = String(v);
}
},
{ immediate: true }
);
function req(msg: string): Rule {
return { required: true, message: msg, trigger: "blur" };
}
const computedRules = computed<Record<string, Rule[]>>(() => {
const base: Record<string, Rule[]> = {
country: [req(t("user.register.form.select-country"))],
email: [
{
type: "email",
message: t("user.register.form.email-invalid"),
trigger: "blur",
},
],
password: [req(t("user.register.form.password-required"))],
confirmPassword: [req(t("user.register.form.confirm-password-required"))],
};
if (isPersonal.value) {
return {
...base,
// 复杂逻辑:姓/名仅允许字母 + 空格 ( ) . - ' ·,禁止首尾/连续标点
lastname: [
req(t("user.register.form.lastname-required")),
{ validator: nameValidator, trigger: "blur" },
],
firstname: [
req(t("user.register.form.firstname-required")),
{ validator: nameValidator, trigger: "blur" },
],
code: [req(t("user.register.form.code-required"))],
};
}
return {
...base,
// 复杂逻辑:公司名允许 & / , 等符号,禁止首尾/连续标点
company_name: [
req(t("user.register.form.company-name-required")),
{ validator: companyNameValidator, trigger: "blur" },
],
};
});
const customVerifyType = computed(() => ({
type: "email",
settings: {
apiUrl: "/api/pub/get_code",
method: "POST" as const,
params: {
email: formData.email,
type: "0" as const,
},
},
}));
// submit
const userStore = useUserStore();
function buildPayload():
| PersonalAccountRegisterPayload
| CorporateAccountRegisterPayload {
const common: RegisterPayload = {
code: formData.code,
is_company: isPersonal.value ? 0 : 1,
area: `+${formData.area}`,
country: formData.country,
email: formData.email,
phone: `+${formData.area}${formData.phone}`,
password: formData.password,
invitation_code: formData.invitation_code
? Number(formData.invitation_code)
: undefined,
from_type: undefined,
};
if (props.recommendPreset) {
(common as any).recommend = props.recommendPreset;
}
if (props.scopePreset) {
(common as any).scope = props.scopePreset;
}
if (isPersonal.value) {
return {
...common,
lastname: formData.lastname,
firstname: formData.firstname,
};
}
return { ...common, company_name: formData.company_name };
}
async function handleRegister() {
try {
if (!formData.agreed) {
message.error(t("user.register.form.agree-terms"));
return;
}
if (isPersonal.value) {
// 复杂逻辑:提交前二次规范化(与 blur 时一致)
formData.lastname = normalizeName(formData.lastname);
formData.firstname = normalizeName(formData.firstname);
} else {
// 复杂逻辑:企业账号提交前规范化公司名
formData.company_name = normalizeCompanyName(formData.company_name);
}
await userStore.register(buildPayload());
message.success(t("user.register.form.register-success"));
if (router.currentRoute.value?.query?.redirect) {
router.push(
decodeURIComponent(router.currentRoute.value.query.redirect as string)
);
} else {
router.push("/");
}
} catch (err) {}
}
</script>额外提示(i18n 文案别忘了补充):
user.register.form.name-pattern:仅允许字母与 空格 ( ) . - ' ·,且不得以标点开头/结尾或连续标点/空格user.register.form.company-name-pattern:公司名可含字母、数字与 空格 ( ) . - ' · 以及 & / ,,不得以标点开头/结尾或连续标点/空格