"use client"
import * as React from "react"
import { ShieldCheck } from "lucide-react"
import {
IdNumberInput,
maskIdNumber,
} from "@/@ui/id-number-input/id-number-input"
export default function IdNumberInputDemo() {
const [value, setValue] = React.useState("11010519491231002X")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex flex-wrap items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">实名认证</p>
<p className="text-xs text-muted-foreground">核对证件信息后继续</p>
</div>
<p className="font-mono text-[11px] text-muted-foreground tabular-nums">
{maskIdNumber("idcard", value) || "尚未填写"}
</p>
</div>
<div className="flex flex-col gap-3">
{/* 和证件号输入同一条窄屏规则:容器窄于 20rem 时标签挪到条上方,同一张卡片里的字段才一致 */}
<div className="@container/field flex flex-col gap-1.5">
<span
aria-hidden="true"
className="hidden text-xs text-muted-foreground @max-[20rem]/field:block"
>
姓名
</span>
<label className="flex h-9 min-w-0 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm focus-within:border-foreground/25 focus-within:bg-background @max-[20rem]/field:px-2.5 dark:focus-within:bg-input/30 pointer-coarse:h-11">
<span className="shrink-0 text-muted-foreground @max-[20rem]/field:sr-only">
姓名
</span>
<input
type="text"
name="realName"
autoComplete="name"
defaultValue="张女士"
className="min-w-0 flex-1 bg-transparent text-right text-base outline-none md:text-sm @max-[20rem]/field:text-left"
/>
</label>
</div>
<IdNumberInput
label="身份证号"
size="lg"
value={value}
onValueChange={setValue}
/>
</div>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="size-3 shrink-0" aria-hidden="true" />
示例号码仅用于演示格式与校验
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/id-number-input.json用法
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 仍含完整号码,不能替代后端权限控制、加密或脱敏处理。
示例
三种号码与错误态
"use client"
import * as React from "react"
import {
IdNumberInput,
isValidUscc,
} from "@/@ui/id-number-input/id-number-input"
/**
* 三种号码:身份证、银行卡失焦打码;信用代码故意把末位写错(正确是 Y43),
* 18 位填满且校验位不对时提示,改对后提示立即消失。
*/
export default function IdNumberInputKindsDemo() {
const [uscc, setUscc] = React.useState("91350100M000100Y44")
const wrong = uscc.length === 18 && !isValidUscc(uscc)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">三种号码</p>
<p className="truncate text-xs text-muted-foreground">
聚焦查看明文,失焦恢复打码
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
GB 11643 · GB 32100
</p>
</div>
<div className="flex flex-col gap-3">
<IdNumberInput
label="身份证号"
defaultValue="11010519491231002X"
mask
/>
<IdNumberInput
kind="bankcard"
label="银行卡号"
defaultValue="6222021234567890123"
mask
showLength
/>
<IdNumberInput
kind="uscc"
label="信用代码"
value={uscc}
onValueChange={setUscc}
error={wrong ? "校验位不正确,请核对证件上的末位字符" : undefined}
/>
</div>
</div>
)
}
表单提交与重置
试试修改校验位再提交,或点击重置恢复初始值。此示例会展示完整 FormData,仅用于说明提交行为。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { IdNumberInput } from "@/@ui/id-number-input/id-number-input"
export default function IdNumberInputFormDemo() {
const [submitted, setSubmitted] = React.useState<string | null>(null)
return (
<form
className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs"
onSubmit={(event) => {
event.preventDefault()
setSubmitted(
JSON.stringify(
Object.fromEntries(new FormData(event.currentTarget)),
null,
2,
),
)
}}
onReset={() => setSubmitted(null)}
>
<div className="pb-3">
<p className="text-sm font-medium">企业开票信息</p>
<p className="text-xs text-muted-foreground">
提交原始号码,重置后恢复示例值
</p>
</div>
<div className="flex flex-col gap-3">
{/* 和证件号输入同一条窄屏规则:容器窄于 20rem 时标签挪到条上方,同一张卡片里的字段才一致 */}
<div className="@container/field flex flex-col gap-1.5">
<span
aria-hidden="true"
className="hidden text-xs text-muted-foreground @max-[20rem]/field:block"
>
发票抬头
</span>
<label className="flex h-8 min-w-0 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm focus-within:border-foreground/25 focus-within:bg-background @max-[20rem]/field:px-2.5 dark:focus-within:bg-input/30 pointer-coarse:h-10">
<span className="shrink-0 text-muted-foreground @max-[20rem]/field:sr-only">
发票抬头
</span>
<input
type="text"
name="title"
autoComplete="organization"
defaultValue="示例科技有限公司"
required
className="min-w-0 flex-1 bg-transparent text-right text-base outline-none md:text-sm @max-[20rem]/field:text-left"
/>
</label>
</div>
<IdNumberInput
kind="uscc"
label="信用代码"
name="uscc"
defaultValue="91350100M000100Y43"
required
/>
<IdNumberInput
kind="bankcard"
label="银行卡号"
name="bankcard"
defaultValue="6222021234567890123"
bankcardCheck="length"
required
showLength
/>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button type="reset" variant="outline">
重置
</Button>
<Button type="submit">提交</Button>
</div>
<pre
className="mt-3 overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-[11px]"
aria-live="polite"
>
{submitted ?? "尚未提交"}
</pre>
</form>
)
}
API
号码类型,默认身份证号。
类型IdNumberKind | undefined不带分隔符的原始号码;小写字母自动转大写。
类型string | undefined非受控初始值,默认空串。
类型string | undefined变化后的原始号码。
类型((value: string) => void) | undefined方条内左侧标签。
类型React.ReactNode高度 28 / 32 / 36px,触屏上 36 / 40 / 44px。
类型IdNumberInputSize | undefined外部错误优先显示,并阻止表单提交。
类型React.ReactNode接受 15 位旧身份证,只检查位数和日期,不转换。默认 false。
类型boolean | undefined银行卡默认只校验 16–19 位;可选 Luhn 校验。
类型"length" | "luhn" | undefined身份证校验通过后显示出生日期与性别,默认 true。
类型boolean | undefined银行卡显示位数,默认 false。
类型boolean | undefined未聚焦时打码;只影响显示,默认 false。
类型boolean | undefinedPartial<{ placeholder: string; invalid: string; required: string; male: string; female: string; parsed: string; length: string; }> | undefinedclassName 作用于字段容器;其他原生输入属性透传给可见输入,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;触屏自动加高一档,并排按钮使用同一档。
源码
"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"
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 }
"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 }
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 }
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 }
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 }
"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 }