跳至正文
组件

ID Number Input证件号输入

身份证号、银行卡号与统一社会信用代码输入:自动整理与分组、保持光标、失焦校验,支持打码显示和原始值提交。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/id-number-input.json

用法

components/identity-form.tsx
import { IdNumberInput } from "@/components/ui/id-number-input/id-number-input"

export function IdentityForm() {
  return (
    <div className="flex flex-col gap-3">
      <IdNumberInput kind="idcard" label="身份证号" name="idcard" required />
      <IdNumberInput kind="bankcard" label="银行卡号" name="bankcard" />
      <IdNumberInput kind="uscc" label="信用代码" name="uscc" />
    </div>
  )
}

kind 默认是 idcard。value、defaultValue、onValueChange 使用不带分隔空格的大写原始号码;空串表示未填写。设置 name 后,隐藏输入提交同样的原始值,form 可关联外部表单。

输入整理

  • 身份证号按 6-8-4 分组;银行卡号每 4 位分组;统一社会信用代码不分组。
  • 全角字符转半角、字母转大写。身份证只保留数字,X 只能在第 18 位;银行卡只保留数字;信用代码只保留规定字符集。
  • 粘贴 110105-19491231-002x 会整理为 110105 19491231 002X。输入时只过滤、分组,不提前报错;IME 组合期间不整理。
  • 增删后按有效字符数恢复光标;在分隔空格旁退格或删除,会越过空格删除相邻字符。

校验规则

类型规则依据
身份证号18 位;出生日期合法且不晚于今天;ISO 7064 MOD 11-2 校验位GB 11643-1999
旧身份证号默认拒绝;allowLegacy15 开启后仅校验 15 位数字与出生日期(年份按 19xx 解析),不转换兼容选项
银行卡号默认仅校验 16–19 位数字;bankcardCheck="luhn" 增加 Luhn 校验长度规则与可选 Luhn 算法
统一社会信用代码18 位,字符集 0123456789ABCDEFGHJKLMNPQRTUWXY,按规定权重计算校验位GB 32100-2015

国内并非所有借记卡都满足 Luhn,因此银行卡默认使用 bankcardCheck="length"。组件不识别银行名称,也不包含 BIN 数据。

失焦后显示错误;required 的空值也会提示。表单提交前,setCustomValidity 会拦住无效号码。外部业务错误通过 error 传入,优先显示并拦截提交;清除后恢复内置校验。readOnly 遵循浏览器行为,不参与原生约束校验。

身份证有效时,默认显示出生日期与性别(按顺序码奇偶解析),可用 showParsed={false} 关闭;银行卡可用 showLength 显示位数。

键盘与打码

身份证末位可能是 X,所以身份证和信用代码使用 inputMode="text"、autoCapitalize="characters",保留输入字母的能力;银行卡使用数字键盘。触屏高度为 36 / 40 / 44px,字号不小于 16px。

mask 在未聚焦时打码,聚焦后恢复明文:身份证保留前 4 后 4 位,银行卡保留后 4 位,信用代码保留前 2 后 4 位。未填完整、无法保留两端的短号码全部打码。身份证打码时也隐藏解析提示。

打码只影响显示,value、回调、隐藏输入和 FormData 仍含完整号码,不能替代后端权限控制、加密或脱敏处理。

示例

三种号码与错误态

正在加载…

表单提交与重置

试试修改校验位再提交,或点击重置恢复初始值。此示例会展示完整 FormData,仅用于说明提交行为。

正在加载…

API

className 作用于字段容器;其他原生输入属性透传给可见输入,ref 指向该输入。name 只用于提交原始值的隐藏输入;form 同时关联两个输入。inputMode、autoCapitalize 由 kind 决定。

工具函数

组件入口重新导出以下纯函数;服务端使用时可直接从 utils.ts 导入。

isValidIdCard("11010519491231002X") // true
isValidIdCard("110105491231002", { allowLegacy15: true }) // true
parseIdCard("11010519491231002X")
// { birthDate: "1949-12-31", gender: "female" }
isValidBankCard("4111111111111111", { luhn: true }) // true
isValidUscc("91350100M000100Y43") // true
formatIdNumber("idcard", "11010519491231002X") // "110105 19491231 002X"
maskIdNumber("bankcard", "6222021234567890123") // "**** **** **** ***0 123"

parseIdCard(value, { allowLegacy15 }) 只解析校验通过的号码,否则返回 null。isValidBankCard(value, { luhn }) 的 luhn 默认 false。校验函数要求原始大写号码,不会先过滤非法字符;格式化和打码函数会整理输入。

labels

type IdNumberInputLabels = {
  placeholder: string // 按 kind 默认为「请输入身份证号」等
  invalid: string // 按 kind 默认为「请输入正确的身份证号」等
  required: string // 按 kind 默认为「请输入身份证号」等
  male: string // "男"
  female: string // "女"
  parsed: string // "{birthDate} · {gender}"
  length: string // "{length} 位"
}

通过 labels?: Partial<IdNumberInputLabels> 覆盖;placeholder 属性优先于 labels.placeholder。日期按 zh-CN 格式化,parsed 中的占位符可以调整排列。

类型

type IdNumberKind = "idcard" | "bankcard" | "uscc"
type IdNumberInputSize = "sm" | "default" | "lg"
type IdCardOptions = { allowLegacy15?: boolean }
type ParsedIdCard = {
  birthDate: string // YYYY-MM-DD
  gender: "male" | "female"
}

以上类型以及 IdNumberInputProps、IdNumberInputLabels 均从组件入口导出。

data-slot

元素data-slot
字段容器id-number-input
方条id-number-input-control
标签id-number-input-label
输入框id-number-input-input
解析或长度提示id-number-input-hint
错误提示id-number-input-error
提交用隐藏输入id-number-input-value

容器带 data-kind、data-size,出错时带 data-invalid,禁用时带 data-disabled。

可访问性

  • 标签通过 htmlFor 关联输入;没有可见 label 时传 aria-label。窄容器内标签在视觉上收起,读屏仍能读取。
  • 错误态包含文字和 aria-invalid;提示、错误均通过 aria-describedby 关联,并以 aria-live="polite" 播报更新。
  • 保留原生文本输入的键盘操作;表单校验失败时浏览器聚焦无效输入。聚焦外框有清晰的焦点环。

注意事项

  • 号码格式和校验位合法,不代表证件真实、身份已核实或银行卡可用;业务端仍需独立校验。
  • 不要把完整号码写入日志、埋点或错误报告;示例数据也不要替换成真实用户资料。
  • 非受控表单重置回到初始 defaultValue,受控模式由调用方重置;切换 kind 时建议同步清空值。
  • 外部传入值会先整理再显示与提交;受控模式应同时处理 onValueChange。
  • 不检查身份证行政区划代码是否存在,也不推断银行名称。
  • 实时整理会重写输入框内容,浏览器的撤销栈可能不完整。
  • sm / default / lg 桌面高度为 28 / 32 / 36px;触屏自动加高一档,并排按钮使用同一档。

源码

@ui/id-number-input/id-number-input.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

import { DEFAULT_LABELS, KIND_LABELS } from "./config"
import { idNumberInputControlVariants } from "./id-number-input-variants"
import type { IdNumberInputProps } from "./types"
import { useIdNumberInputState } from "./use-id-number-input-state"
import {
  formatIdNumber,
  isValidBankCard,
  isValidIdCard,
  isValidIdCardAtDate,
  isValidUscc,
  maskIdNumber,
  parseIdCard,
  parseIdCardAtDate,
} from "./utils"

/** 三种号码共用方条外观;显示分组与打码不改变表单提交值。 */
function IdNumberInput({
  kind = "idcard",
  value: valueProp,
  defaultValue = "",
  onValueChange,
  label,
  size = "default",
  error,
  allowLegacy15 = false,
  bankcardCheck = "length",
  showParsed = true,
  showLength = false,
  mask = false,
  labels: labelsProp,
  className,
  id: idProp,
  name,
  form,
  required,
  disabled,
  readOnly,
  placeholder,
  "aria-invalid": ariaInvalid,
  "aria-describedby": describedByProp,
  onFocus,
  onBlur,
  onCompositionStart,
  onCompositionEnd,
  onInvalid,
  ref,
  ...props
}: IdNumberInputProps) {
  const labels = { ...DEFAULT_LABELS, ...KIND_LABELS[kind], ...labelsProp }
  const autoId = React.useId()
  const id = idProp ?? autoId
  const errorId = `${id}-error`
  const hintId = `${id}-hint`
  const inputRef = React.useRef<HTMLInputElement>(null)
  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
  const {
    today,
    value,
    focused,
    setFocused,
    touched,
    setTouched,
    composingRef,
    draft,
    setDraft,
    setCaret,
    commit,
  } = useIdNumberInputState({
    kind,
    value: valueProp,
    defaultValue,
    onValueChange,
    form,
    inputRef,
  })
  const valid =
    kind === "idcard"
      ? isValidIdCardAtDate(value, { allowLegacy15 }, today)
      : kind === "bankcard"
        ? isValidBankCard(value, { luhn: bankcardCheck === "luhn" })
        : isValidUscc(value)
  const ownMessage =
    value === ""
      ? required
        ? labels.required
        : ""
      : valid
        ? ""
        : labels.invalid
  const errorMessage = error || (touched ? ownMessage : "")
  const invalid =
    Boolean(errorMessage) || ariaInvalid === true || ariaInvalid === "true"
  React.useEffect(() => {
    inputRef.current?.setCustomValidity(
      error ? (typeof error === "string" ? error : labels.invalid) : ownMessage,
    )
  }, [error, labels.invalid, ownMessage])

  const parsed =
    kind === "idcard" && showParsed && valid
      ? parseIdCardAtDate(value, { allowLegacy15 }, today)
      : null
  const hint = parsed
    ? labels.parsed
        .replace(
          "{birthDate}",
          // 数字与「年月日」之间留空格,和全站「第 2 步」「28 / 32 / 36」的排版一致
          parsed.birthDate
            .split("-")
            .map(Number)
            .map((part, i) => `${part} ${"年月日"[i]}`)
            .join(" "),
        )
        .replace("{gender}", labels[parsed.gender])
    : kind === "bankcard" && showLength && value
      ? labels.length.replace(
          "{length}",
          new Intl.NumberFormat("zh-CN").format(value.length),
        )
      : ""
  // 打码时也不在提示中暴露出生日期。
  const visibleHint =
    hint && !(mask && !focused && kind === "idcard") && !errorMessage
      ? hint
      : ""
  const describedBy =
    [
      describedByProp,
      visibleHint ? hintId : undefined,
      errorMessage ? errorId : undefined,
    ]
      .filter(Boolean)
      .join(" ") || undefined

  return (
    <div
      data-slot="id-number-input"
      data-kind={kind}
      data-size={size}
      data-invalid={invalid ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      className={cn(
        "@container/id-number-input flex w-full min-w-0 flex-col gap-1.5",
        className,
      )}
    >
      {/* 窄于 20rem 时条内放不下「标签 + 号码」:标签挪到条上方显示,条内那份只留给读屏。
          这一份 aria-hidden,只负责看和点(点它聚焦输入框),可访问名称仍来自条内的 label */}
      {label && (
        <label
          aria-hidden="true"
          htmlFor={id}
          data-slot="id-number-input-label-above"
          className="hidden text-xs text-muted-foreground @max-[20rem]/id-number-input:block"
        >
          {label}
        </label>
      )}
      <div
        data-slot="id-number-input-control"
        className={cn(
          idNumberInputControlVariants({ size }),
          "@max-[24rem]/id-number-input:gap-2 @max-[24rem]/id-number-input:px-2.5",
          invalid
            ? "border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40"
            : "border-edge focus-within:border-foreground/25 focus-within:bg-background hover:not-focus-within:bg-muted dark:focus-within:bg-input/30",
          disabled && "cursor-not-allowed opacity-50",
          readOnly && "bg-muted/30",
        )}
        onMouseDown={(event) => {
          if (event.target === event.currentTarget) {
            event.preventDefault()
            inputRef.current?.focus()
          }
        }}
      >
        {label && (
          <label
            data-slot="id-number-input-label"
            htmlFor={id}
            className="shrink-0 text-muted-foreground max-md:text-sm @max-[20rem]/id-number-input:sr-only"
          >
            {label}
          </label>
        )}
        <input
          {...props}
          ref={inputRef}
          id={id}
          form={form}
          data-slot="id-number-input-input"
          type="text"
          inputMode={kind === "bankcard" ? "numeric" : "text"}
          autoCapitalize={kind === "bankcard" ? "off" : "characters"}
          autoComplete={props.autoComplete ?? "off"}
          spellCheck={false}
          value={
            draft ??
            (mask && !focused
              ? maskIdNumber(kind, value)
              : formatIdNumber(kind, value))
          }
          placeholder={placeholder ?? labels.placeholder}
          disabled={disabled}
          readOnly={readOnly}
          required={required}
          aria-invalid={invalid || undefined}
          aria-describedby={describedBy}
          className={cn(
            "h-full min-w-0 flex-1 bg-transparent font-medium tabular-nums outline-none placeholder:font-normal placeholder:text-muted-foreground disabled:cursor-not-allowed",
            label && "text-right @max-[20rem]/id-number-input:text-left",
          )}
          onChange={(event) => {
            const input = event.currentTarget
            if (composingRef.current) {
              setDraft(input.value)
              return
            }
            commit(input.value, input.selectionStart ?? input.value.length)
          }}
          onCompositionStart={(event) => {
            composingRef.current = true
            setDraft(event.currentTarget.value)
            onCompositionStart?.(event)
          }}
          onCompositionEnd={(event) => {
            composingRef.current = false
            setDraft(null)
            commit(
              event.currentTarget.value,
              event.currentTarget.selectionStart ??
                event.currentTarget.value.length,
            )
            onCompositionEnd?.(event)
          }}
          onKeyDown={(event) => {
            props.onKeyDown?.(event)
            const input = event.currentTarget
            const { selectionStart: start, selectionEnd: end } = input
            if (
              event.defaultPrevented ||
              readOnly ||
              composingRef.current ||
              event.nativeEvent.isComposing ||
              start === null ||
              start !== end
            )
              return
            const current = input.value
            if (event.key === "Backspace" && current[start - 1] === " ") {
              event.preventDefault()
              commit(
                current.slice(0, start - 2) + current.slice(start),
                start - 2,
              )
            } else if (event.key === "Delete" && current[start] === " ") {
              event.preventDefault()
              commit(current.slice(0, start) + current.slice(start + 2), start)
            }
          }}
          onFocus={(event) => {
            setFocused(true)
            if (mask) setCaret({ position: formatIdNumber(kind, value).length })
            onFocus?.(event)
          }}
          onBlur={(event) => {
            setFocused(false)
            setTouched(true)
            onBlur?.(event)
          }}
          onInvalid={(event) => {
            setTouched(true)
            onInvalid?.(event)
          }}
        />
      </div>
      {visibleHint && (
        <p
          id={hintId}
          data-slot="id-number-input-hint"
          className="text-xs text-muted-foreground"
          aria-live="polite"
        >
          {visibleHint}
        </p>
      )}
      {errorMessage && (
        <p
          id={errorId}
          data-slot="id-number-input-error"
          className="text-xs text-destructive"
          aria-live="polite"
        >
          {errorMessage}
        </p>
      )}
      {name && (
        <input
          type="hidden"
          data-slot="id-number-input-value"
          name={name}
          form={form}
          value={value}
          disabled={disabled}
        />
      )}
    </div>
  )
}

export {
  IdNumberInput,
  formatIdNumber,
  idNumberInputControlVariants,
  isValidBankCard,
  isValidIdCard,
  isValidUscc,
  maskIdNumber,
  parseIdCard,
}
export type {
  IdNumberInputProps,
  IdNumberInputSize,
  IdNumberKind,
} from "./types"
export type { IdNumberInputLabels } from "./config"
export type { IdCardOptions, ParsedIdCard } from "./utils"
@ui/id-number-input/id-number-input-variants.ts
import { cva } from "class-variance-authority"

const idNumberInputControlVariants = cva(
  "flex w-full min-w-0 cursor-text items-center gap-3 rounded-md border bg-muted/60 px-3 transition-colors motion-reduce:transition-none",
  {
    variants: {
      size: {
        sm: "h-7 text-base pointer-coarse:h-9 md:text-xs",
        default: "h-8 text-base pointer-coarse:h-10 md:text-sm",
        lg: "h-9 text-base pointer-coarse:h-11 md:text-sm",
      },
    },
    defaultVariants: { size: "default" },
  },
)

export { idNumberInputControlVariants }
@ui/id-number-input/use-id-number-input-state.ts
"use client"

import * as React from "react"

import { useControllableState } from "@/hooks/use-controllable-state"

import type { IdNumberInputProps } from "./types"
import { currentIdCardDate, normalizeIdNumber, sanitizeIdNumber } from "./utils"

// 服务端使用稳定快照;当前日期只在挂载后读取,并在跨日后重新校验。
function subscribeToDate(callback: () => void) {
  const timer = window.setInterval(callback, 60_000)
  return () => window.clearInterval(timer)
}
function serverDate() {
  return 0
}

/** 统一管理原始值、组合输入、光标恢复与所属表单的重置。 */
function useIdNumberInputState({
  kind,
  value: valueProp,
  defaultValue,
  onValueChange,
  form,
  inputRef,
}: Required<Pick<IdNumberInputProps, "kind" | "defaultValue">> &
  Pick<IdNumberInputProps, "value" | "onValueChange" | "form"> & {
    inputRef: React.RefObject<HTMLInputElement | null>
  }) {
  const today = React.useSyncExternalStore(
    subscribeToDate,
    currentIdCardDate,
    serverDate,
  )
  const initialValue = React.useRef(defaultValue)
  const [state, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: normalizeIdNumber(kind, defaultValue),
    onChange: onValueChange,
  })
  const value = normalizeIdNumber(kind, state)
  const [focused, setFocused] = React.useState(false)
  const [touched, setTouched] = React.useState(false)
  const composingRef = React.useRef(false)
  const [draft, setDraft] = React.useState<string | null>(null)
  const [caret, setCaret] = React.useState<{ position: number } | null>(null)
  React.useLayoutEffect(() => {
    const input = inputRef.current
    if (caret && input && document.activeElement === input)
      input.setSelectionRange(caret.position, caret.position)
  }, [caret, inputRef])

  const commit = (raw: string, rawCaret: number) => {
    const next = sanitizeIdNumber(kind, raw, rawCaret)
    setValue(next.value)
    setCaret({ position: next.caret })
  }
  const controlled = valueProp !== undefined
  const resetRef = React.useRef(() => {})
  React.useEffect(() => {
    resetRef.current = () => {
      setValue(normalizeIdNumber(kind, initialValue.current))
      setTouched(false)
      setDraft(null)
      composingRef.current = false
    }
  })
  React.useEffect(() => {
    const element = inputRef.current?.form
    if (!element || controlled) return
    let timer: ReturnType<typeof setTimeout> | undefined
    const reset = (event: Event) => {
      timer = setTimeout(() => {
        if (!event.defaultPrevented) resetRef.current()
      })
    }
    element.addEventListener("reset", reset)
    return () => {
      element.removeEventListener("reset", reset)
      clearTimeout(timer)
    }
  }, [controlled, form, inputRef])

  return {
    today,
    value,
    focused,
    setFocused,
    touched,
    setTouched,
    composingRef,
    draft,
    setDraft,
    setCaret,
    commit,
  }
}

export { useIdNumberInputState }
@ui/id-number-input/types.ts
import type * as React from "react"

import type { IdNumberInputLabels } from "./config"

type IdNumberKind = "idcard" | "bankcard" | "uscc"
type IdNumberInputSize = "sm" | "default" | "lg"

type IdNumberInputProps = Omit<
  React.ComponentProps<"input">,
  | "value"
  | "defaultValue"
  | "onChange"
  | "type"
  | "size"
  | "children"
  | "inputMode"
  | "autoCapitalize"
> & {
  /** 号码类型,默认身份证号。 */
  kind?: IdNumberKind
  /** 不带分隔符的原始号码;小写字母自动转大写。 */
  value?: string
  /** 非受控初始值,默认空串。 */
  defaultValue?: string
  /** 变化后的原始号码。 */
  onValueChange?: (value: string) => void
  /** 方条内左侧标签。 */
  label?: React.ReactNode
  /** 高度 28 / 32 / 36px,触屏上 36 / 40 / 44px。 */
  size?: IdNumberInputSize
  /** 外部错误优先显示,并阻止表单提交。 */
  error?: React.ReactNode
  /** 接受 15 位旧身份证,只检查位数和日期,不转换。默认 false。 */
  allowLegacy15?: boolean
  /** 银行卡默认只校验 16–19 位;可选 Luhn 校验。 */
  bankcardCheck?: "length" | "luhn"
  /** 身份证校验通过后显示出生日期与性别,默认 true。 */
  showParsed?: boolean
  /** 银行卡显示位数,默认 false。 */
  showLength?: boolean
  /** 未聚焦时打码;只影响显示,默认 false。 */
  mask?: boolean
  labels?: Partial<IdNumberInputLabels>
}

export type { IdNumberInputProps, IdNumberInputSize, IdNumberKind }
@ui/id-number-input/config.ts
const KIND_LABELS = {
  idcard: {
    placeholder: "请输入身份证号",
    invalid: "请输入正确的身份证号",
    required: "请输入身份证号",
  },
  bankcard: {
    placeholder: "请输入银行卡号",
    invalid: "请输入正确的银行卡号",
    required: "请输入银行卡号",
  },
  uscc: {
    placeholder: "请输入统一社会信用代码",
    invalid: "请输入正确的统一社会信用代码",
    required: "请输入统一社会信用代码",
  },
}

const DEFAULT_LABELS = {
  ...KIND_LABELS.idcard,
  male: "男",
  female: "女",
  parsed: "{birthDate} · {gender}",
  length: "{length} 位",
}

type IdNumberInputLabels = typeof DEFAULT_LABELS

export { DEFAULT_LABELS, KIND_LABELS }
export type { IdNumberInputLabels }
@ui/id-number-input/utils.ts
import type { IdNumberKind } from "./types"

const USCC_CHARACTERS = "0123456789ABCDEFGHJKLMNPQRTUWXY"
const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const USCC_WEIGHTS = [
  1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28,
]

type IdCardOptions = { allowLegacy15?: boolean }
type ParsedIdCard = { birthDate: string; gender: "male" | "female" }

/** 仅用于输入整理;校验函数不会把不合法的原始值悄悄修正。 */
function normalizeIdNumber(kind: IdNumberKind, value: string) {
  let result = ""
  for (const character of value.normalize("NFKC").toUpperCase()) {
    if (kind === "uscc") {
      if (USCC_CHARACTERS.includes(character)) result += character
    } else if (
      /\d/.test(character) ||
      (kind === "idcard" && character === "X" && result.length === 17)
    ) {
      result += character
    }
    if (result.length === (kind === "bankcard" ? 19 : 18)) break
  }
  return result
}

function groupNumber(kind: IdNumberKind, value: string) {
  if (kind === "uscc") return value
  if (kind === "bankcard") return value.match(/.{1,4}/g)?.join(" ") ?? ""
  return [value.slice(0, 6), value.slice(6, 14), value.slice(14)]
    .filter(Boolean)
    .join(" ")
}

function formatIdNumber(kind: IdNumberKind, value: string) {
  return groupNumber(kind, normalizeIdNumber(kind, value))
}

/** 短号码也隐藏敏感部分,避免输入未完成时泄露全部内容。 */
function maskIdNumber(kind: IdNumberKind, value: string) {
  const raw = normalizeIdNumber(kind, value)
  if (raw.length <= 4) return "*".repeat(raw.length)
  // 打码后不再沿用输入时的分组:分组会把露出的末四位拆散(如 ***0 123)。
  // 银行卡固定写成「**** **** **** 末四位」,长度不同的卡看起来一致;其余连写
  if (kind === "bankcard") return `**** **** **** ${raw.slice(-4)}`
  const prefix = kind === "idcard" ? 4 : 2
  if (raw.length <= prefix + 4) return "*".repeat(raw.length)
  return (
    raw.slice(0, prefix) + "*".repeat(raw.length - prefix - 4) + raw.slice(-4)
  )
}

function currentIdCardDate() {
  const now = new Date()
  return now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate()
}

function birthDateOf(value: string, today: number) {
  const digits =
    value.length === 15 ? `19${value.slice(6, 12)}` : value.slice(6, 14)
  const year = Number(digits.slice(0, 4))
  const month = Number(digits.slice(4, 6))
  const day = Number(digits.slice(6, 8))
  if (year < 1 || month < 1 || month > 12 || day < 1) return null
  const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
  if (
    day >
    [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
  )
    return null
  if (Number(digits) > today) return null
  return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`
}

function isValidIdCardAtDate(
  value: string,
  { allowLegacy15 = false }: IdCardOptions,
  today: number,
) {
  if (allowLegacy15 && /^\d{15}$/.test(value))
    return birthDateOf(value, today) !== null
  if (!/^\d{17}[\dX]$/.test(value) || !birthDateOf(value, today)) return false
  const sum = ID_WEIGHTS.reduce(
    (total, weight, index) => total + Number(value[index]) * weight,
    0,
  )
  return "10X98765432"[sum % 11] === value[17]
}

function parseIdCardAtDate(
  value: string,
  options: IdCardOptions,
  today: number,
): ParsedIdCard | null {
  if (!isValidIdCardAtDate(value, options, today)) return null
  return {
    birthDate: birthDateOf(value, today)!,
    gender:
      Number(value[value.length === 15 ? 14 : 16]) % 2 ? "male" : "female",
  }
}

function isValidIdCard(value: string, options: IdCardOptions = {}) {
  return isValidIdCardAtDate(value, options, currentIdCardDate())
}

function parseIdCard(
  value: string,
  options: IdCardOptions = {},
): ParsedIdCard | null {
  return parseIdCardAtDate(value, options, currentIdCardDate())
}

function isValidBankCard(
  value: string,
  { luhn = false }: { luhn?: boolean } = {},
) {
  if (!/^\d{16,19}$/.test(value)) return false
  if (!luhn) return true
  let sum = 0
  for (let index = value.length - 1; index >= 0; index--) {
    let digit = Number(value[index])
    if ((value.length - 1 - index) % 2 === 1) {
      digit *= 2
      if (digit > 9) digit -= 9
    }
    sum += digit
  }
  return sum % 10 === 0
}

function isValidUscc(value: string) {
  if (
    value.length !== 18 ||
    [...value].some((character) => !USCC_CHARACTERS.includes(character))
  )
    return false
  const sum = USCC_WEIGHTS.reduce(
    (total, weight, index) =>
      total + USCC_CHARACTERS.indexOf(value[index]) * weight,
    0,
  )
  return USCC_CHARACTERS[(31 - (sum % 31)) % 31] === value[17]
}

/** 光标按整理后保留的字符数换算,非法字符和分隔符不占位置。 */
function sanitizeIdNumber(kind: IdNumberKind, raw: string, caret: number) {
  const value = normalizeIdNumber(kind, raw)
  const count = Math.min(
    normalizeIdNumber(kind, raw.slice(0, caret)).length,
    value.length,
  )
  const text = formatIdNumber(kind, value)
  let position = 0
  let seen = 0
  while (position < text.length && seen < count) {
    if (text[position] !== " ") seen++
    position++
  }
  return { value, caret: position }
}

export {
  currentIdCardDate,
  isValidIdCardAtDate,
  parseIdCardAtDate,
  formatIdNumber,
  isValidBankCard,
  isValidIdCard,
  isValidUscc,
  maskIdNumber,
  normalizeIdNumber,
  parseIdCard,
  sanitizeIdNumber,
}
export type { IdCardOptions, ParsedIdCard }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }