Skip to content

总结版(先给结论)

  • 不要过度收紧:全球姓名形态多样,白名单过窄会伤害用户体验。
  • 推荐做法最小必需限制 + 长度与内容检查 + 规范化处理 + 人类可读反馈
  • 允许字符集合(推荐):Unicode 字母(\p{L}) + 组合音标(\p{M}) + 空格 + 连字符(-) + 撇号(' 与 ’) + 点号(.) + 圆括号( )
  • 禁止:数字、下划线、@/#、表情符号、控制/不可见字符(如零宽字符)、多空格/多标点连写、首尾标点/空格。
  • 长度建议1 ~ 70字素(grapheme),保证像“👨‍👩‍👧‍👦”等不会混入;但对极短名字(如“冯”“Ö”)要允许。
  • 存储:保留原始输入(审计/回显),同时保存规范化副本用于检索/去重。
  • 可选:后端做脚本混用检测(防钓鱼,如拉丁 + 西里尔混用),并记录语言脚本以供风控参考。

设计原则

  1. 国际化友好:姓名并非仅“字母+空格”,还会包含重音符号、连字符、撇号、复姓、括注(如 (Jr.))。
  2. 可访问性:允许用户粘贴内容,但清理不可见字符与多余空白。
  3. 可解释错误:错误提示明确指出允许的字符与修复建议
  4. 安全性:阻断控制字符、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公司名可含字母、数字与 空格 ( ) . - ' · 以及 & / ,,不得以标点开头/结尾或连续标点/空格

本站总访问