Files
STP/front-end/src/app/pages/RegisterPage.tsx
T
vame 3dba6db9d2 feat(frontend): 移除万华相关元素并更新门户登录展示
统一清理前端万华相关文案与公司展示口径为滨化集团,移除登录与门户页角色选框,并同步更新全量页面截图及Word汇总文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:48:44 +08:00

593 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { useNavigate, Link } from "react-router";
import {
Hexagon,
ArrowLeft,
ArrowRight,
UploadCloud,
CheckCircle2,
Loader2,
XCircle,
Building2,
UserRound,
ShieldCheck,
X,
Eye,
EyeOff,
Info,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { cn } from "../components/ui/utils";
type Registrant = "supplier" | "expert";
type AuditState = "idle" | "auditing" | "success" | "rejected";
const SUPPLIER_STEPS = ["企业信息", "联系人", "提交 / 自动审核"];
const EXPERT_STEPS = ["基本信息", "专业资质 / 收款", "提交审核"];
interface PwdRule {
label: string;
test: (v: string) => boolean;
}
const PWD_RULES: PwdRule[] = [
{ label: "长度 8~20 位", test: (v) => v.length >= 8 && v.length <= 20 },
{ label: "包含大写字母(A-Z)与小写字母(a-z)", test: (v) => /[A-Z]/.test(v) && /[a-z]/.test(v) },
{ label: "包含数字(0-9)与特殊字符(!@#$%^&*", test: (v) => /\d/.test(v) && /[!@#$%^&*()_+\-=]/.test(v) },
{ label: "禁止连续/重复字符(如 12345678、aaaa", test: (v) => !/(.)\1{3,}/.test(v) && !/0123|1234|2345|3456|4567|5678|6789/.test(v) },
{ label: "非常见弱口令(password、admin123 等)", test: (v) => !["password", "admin123", "12345678", "qwerty"].includes(v.toLowerCase()) },
];
function Field({
label,
placeholder,
required,
hint,
className,
type = "text",
readOnly,
}: {
label: string;
placeholder?: string;
required?: boolean;
hint?: string;
className?: string;
type?: string;
readOnly?: boolean;
}) {
return (
<div className={className}>
<label className="mb-1.5 block text-sm text-[#4a5568]">
{label} {required && <span className="text-[#e91e63]">*</span>}
</label>
<input
type={type}
readOnly={readOnly}
placeholder={placeholder}
className={cn(
"h-11 w-full rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary",
readOnly && "bg-muted text-muted-foreground",
)}
/>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
function StepBar({ steps, current }: { steps: string[]; current: number }) {
return (
<div className="flex items-center justify-center rounded-full border bg-white px-6 py-4 shadow-sm">
{steps.map((s, i) => (
<div key={s} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={cn(
"flex size-9 items-center justify-center rounded-full text-sm font-bold transition-colors",
i <= current ? "bg-brand-gradient text-white" : "bg-muted text-muted-foreground",
)}
>
{i < current ? <CheckCircle2 className="size-5" /> : i + 1}
</div>
<span className={cn("mt-2 text-xs", i <= current ? "font-medium text-primary" : "text-muted-foreground")}>{s}</span>
</div>
{i < steps.length - 1 && (
<div className={cn("mx-3 mb-6 h-0.5 w-16 rounded-full sm:w-24", i < current ? "bg-brand-gradient" : "bg-border")} />
)}
</div>
))}
</div>
);
}
function Notice({ items }: { items: string[] }) {
return (
<div className="rounded-xl border border-[#ffcc80] bg-[#fff3e0] p-4">
<div className="flex items-center gap-1.5 text-sm font-semibold text-[#e65100]">
<Info className="size-4" />
</div>
<ol className="mt-2 space-y-1.5 text-xs leading-relaxed text-[#bf360c]">
{items.map((t, i) => (
<li key={i}>
{i + 1}. {t}
</li>
))}
</ol>
</div>
);
}
export function RegisterPage() {
const navigate = useNavigate();
const [registrant, setRegistrant] = useState<Registrant>("supplier");
const [step, setStep] = useState(0);
const [ocr, setOcr] = useState<"none" | "scanning" | "done">("none");
const [audit, setAudit] = useState<AuditState>("idle");
const [products, setProducts] = useState<string[]>(["工业阀门", "管道配件"]);
const [productInput, setProductInput] = useState("");
const [pwd, setPwd] = useState("");
const [confirmPwd, setConfirmPwd] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [smsCountdown, setSmsCountdown] = useState(0);
const steps = registrant === "supplier" ? SUPPLIER_STEPS : EXPERT_STEPS;
const passedRules = useMemo(() => PWD_RULES.map((r) => r.test(pwd)), [pwd]);
const passedCount = passedRules.filter(Boolean).length;
const strength: 0 | 1 | 2 = passedCount >= 5 && pwd.length >= 12 ? 2 : passedCount >= 4 ? 1 : 0;
function switchRegistrant(r: Registrant) {
setRegistrant(r);
setStep(0);
setAudit("idle");
setOcr("none");
}
function triggerOcr() {
setOcr("scanning");
setTimeout(() => {
setOcr("done");
toast.success("营业执照识别完成,已自动回填企业信息");
}, 1500);
}
function addProduct() {
const v = productInput.trim();
if (v && products.length < 20 && !products.includes(v)) {
setProducts((p) => [...p, v]);
}
setProductInput("");
}
function sendSms() {
toast.success("验证码已发送");
setSmsCountdown(60);
const t = setInterval(() => {
setSmsCountdown((c) => {
if (c <= 1) {
clearInterval(t);
return 0;
}
return c - 1;
});
}, 1000);
}
function submitAudit() {
setStep(steps.length - 1);
setAudit("auditing");
setTimeout(() => {
setAudit("success");
}, 3800);
}
const isLastFormStep = step === steps.length - 2;
return (
<div className="min-h-screen bg-[#f5f7fa]">
<header className="flex h-16 items-center justify-between border-b bg-card px-8">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-gradient">
<Hexagon className="size-5 text-white" />
</div>
<span className="font-semibold"> · </span>
</div>
<Link to="/" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary">
<ArrowLeft className="size-4" />
</Link>
</header>
<div className="mx-auto max-w-5xl px-6 py-8">
{/* 注册对象选择 */}
<div className="mb-6 flex justify-center gap-3">
{(
[
{ id: "supplier" as Registrant, label: "供应商注册", icon: Building2 },
{ id: "expert" as Registrant, label: "外部专家注册", icon: UserRound },
]
).map((r) => {
const Icon = r.icon;
return (
<button
key={r.id}
onClick={() => switchRegistrant(r.id)}
className={cn(
"flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all",
registrant === r.id
? "border-transparent bg-brand-gradient text-white shadow-sm"
: "border-border bg-white text-muted-foreground hover:border-primary hover:text-primary",
)}
>
<Icon className="size-4" /> {r.label}
</button>
);
})}
</div>
<div className="mb-6 flex items-center justify-between gap-4">
<StepBar steps={steps} current={step} />
<div className="hidden shrink-0 items-center gap-1.5 rounded-full border border-[#4caf50] bg-[#e8f5e9] px-3 py-1.5 text-xs text-[#2e7d32] lg:flex">
<ShieldCheck className="size-3.5" /> AI ·
</div>
</div>
{/* ============ 供应商注册 ============ */}
{registrant === "supplier" && (
<>
{step === 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
<div className="rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
<div className="my-5 h-px bg-border" />
{/* 营业执照上传 */}
<div
onClick={triggerOcr}
className="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary bg-[#fafbfc] py-6 transition-colors hover:bg-secondary/40"
>
<UploadCloud className="mb-2 size-7 text-primary" />
<div className="text-sm font-medium text-primary">JPG/PNG/PDF10MB</div>
<div className="mt-1 text-xs text-muted-foreground">AI </div>
</div>
{ocr === "scanning" && (
<div className="mt-3 flex items-center gap-2 rounded-lg bg-[#fff8e1] px-3 py-2 text-sm text-[#e65100]">
<Loader2 className="size-4 animate-spin" /> ...
</div>
)}
{ocr === "done" && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[#a5d6a7] bg-[#e8f5e9] px-3 py-2 text-sm text-[#2e7d32]">
<CheckCircle2 className="size-4" /> 91370100XXXXXXXXXX
</div>
)}
<div className="mt-6 grid grid-cols-2 gap-x-5 gap-y-4">
<Field label="企业全称" required placeholder="系统自动识别或手动输入" className="col-span-2" />
<Field label="统一社会信用代码" required placeholder="18 位代码" />
<Field label="注册资本(万元)" required placeholder="请输入数字" />
<Field label="法定代表人" required placeholder="请输入法定代表人姓名" />
<Field label="行业分类" required placeholder="请选择或搜索行业(GB/T 4754" />
<Field label="注册地址" required placeholder="请填写详细地址" className="col-span-2" />
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<textarea
rows={2}
placeholder="系统自动识别或手动输入,不超过 500 字"
className="w-full resize-none rounded-lg border bg-input-background px-3 py-2 text-sm outline-none transition-colors focus:border-primary"
/>
</div>
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<div className="flex gap-2">
<input
value={productInput}
onChange={(e) => setProductInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addProduct())}
placeholder="输入产品名称,回车添加"
className="h-11 flex-1 rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary"
/>
<Button type="button" variant="outline" className="h-11" onClick={addProduct}>
+
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{products.map((p) => (
<span
key={p}
className="flex items-center gap-1 rounded-full border border-primary bg-secondary px-3 py-1 text-xs text-primary"
>
{p}
<button onClick={() => setProducts((ps) => ps.filter((x) => x !== p))}>
<X className="size-3" />
</button>
</span>
))}
</div>
</div>
</div>
</div>
<Notice
items={[
"请确保信息与营业执照一致",
"统一社会信用代码将作为唯一标识",
"提交后由 AI 即时自动审核",
"审核通过后可参与投标报名",
"已有 SRM 账号请直接登录",
]}
/>
</div>
)}
{step === 1 && (
<div className="mx-auto max-w-2xl rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<div className="my-5 h-px bg-border" />
<div className="space-y-4">
<Field label="联系人姓名" required placeholder="请输入联系人姓名" hint="必填,最大 64 字符" />
<Field label="联系电话(手机号)" required placeholder="请输入 11 位手机号" hint="必填,格式校验,全局唯一" />
<Field label="电子邮箱" required placeholder="请输入常用邮箱" hint="必填,格式校验,全局唯一" />
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<div className="flex gap-3">
<input
placeholder="点击发送验证码"
className="h-11 flex-1 rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={sendSms}
disabled={smsCountdown > 0}
className="h-11 w-32 shrink-0 rounded-lg bg-brand-gradient text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{smsCountdown > 0 ? `${smsCountdown}s 后重发` : "发送验证码"}
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
{/* 密码 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<div className="relative">
<input
type={showPwd ? "text" : "password"}
value={pwd}
onChange={(e) => setPwd(e.target.value)}
placeholder="请设置登录密码"
className="h-11 w-full rounded-lg border bg-input-background px-3 pr-10 text-sm outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={() => setShowPwd((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPwd ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<input
type={showPwd ? "text" : "password"}
value={confirmPwd}
onChange={(e) => setConfirmPwd(e.target.value)}
placeholder="请再次输入密码"
className={cn(
"h-11 w-full rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary",
confirmPwd && confirmPwd !== pwd && "border-destructive",
)}
/>
</div>
</div>
{/* 密码复杂度要求 */}
<div className="rounded-xl border bg-[#f8fafc] p-4">
<div className="text-sm font-medium text-[#374151]"></div>
<ul className="mt-2 space-y-1.5">
{PWD_RULES.map((r, i) => (
<li key={r.label} className="flex items-center gap-2 text-[13px]">
<span className={passedRules[i] ? "text-[#10b981]" : "text-[#ef4444]"}>{passedRules[i] ? "✔" : "✘"}</span>
<span className="text-[#374151]">{r.label}</span>
</li>
))}
</ul>
{/* 强度条 */}
<div className="mt-3 flex items-center gap-3">
<span className="text-xs text-muted-foreground"></span>
<div className="h-2 w-40 overflow-hidden rounded-full bg-[#e5e7eb]">
<div
className={cn(
"h-full rounded-full transition-all",
strength === 0 ? "w-1/3 bg-[#ef4444]" : strength === 1 ? "w-2/3 bg-[#f59e0b]" : "w-full bg-[#10b981]",
)}
/>
</div>
<span
className={cn(
"text-xs font-medium",
strength === 0 ? "text-[#ef4444]" : strength === 1 ? "text-[#f59e0b]" : "text-[#10b981]",
)}
>
{strength === 0 ? "弱" : strength === 1 ? "中" : "强"}
</span>
</div>
</div>
</div>
</div>
)}
</>
)}
{/* ============ 外部专家注册 ============ */}
{registrant === "expert" && (
<>
{step === 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
<div className="rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
<div className="my-5 h-px bg-border" />
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
<Field label="真实姓名" required placeholder="请输入中文姓名" />
<Field label="性别" required placeholder="请选择" />
<Field label="身份证号码" required placeholder="请输入 18 位身份证号" hint="🔒 将使用 AES-256 加密存储" className="col-span-2" />
<Field label="出生日期" placeholder="由身份证自动识别" readOnly />
<Field label="年龄" placeholder="自动计算" readOnly />
<Field label="手机号码" required placeholder="请输入手机号" />
<Field label="电子邮箱" required placeholder="请输入常用邮箱" />
<Field label="工作单位" placeholder="请输入单位全称" />
<Field label="现从事专业" required placeholder="如:化工工艺、设备管理" />
<Field label="技术职称" required placeholder="高级 / 副高级 / 中级" />
<Field label="职称专业" placeholder="如:化学工程" />
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]">
/ <span className="text-[#e91e63]">*</span>
</label>
<div className="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary bg-[#fafbfc] py-5 hover:bg-secondary/40">
<UploadCloud className="mb-1.5 size-6 text-primary" />
<div className="text-sm text-primary"> PDF/JPG/PNG20MB</div>
</div>
</div>
</div>
</div>
<Notice
items={[
"所有信息须与身份证及职称证书一致",
"滨化集团将对信息进行真实性核验",
"入库专家须遵守《评委管理规定》",
"至少具备中级及以上职称方可入库",
"个人信息变更需及时在线更新",
]}
/>
</div>
)}
{step === 1 && (
<div className="mx-auto max-w-2xl rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<div className="my-5 h-px bg-border" />
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<input
placeholder="如:石油化工、反应工程、分离技术,逗号分隔"
className="h-11 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary"
/>
</div>
<Field label="开户银行" placeholder="如:中国银行北京分行" />
<Field label="银行账号" placeholder="请输入收款账号" hint="🔒 将使用 AES-256 加密存储" />
<label className="col-span-2 mt-2 flex items-start gap-2 rounded-lg border border-[#ffcc80] bg-[#fff3e0] p-3 text-xs text-[#bf360c]">
<input type="checkbox" className="mt-0.5 size-4 accent-[var(--primary)]" />
</label>
</div>
</div>
)}
</>
)}
{/* ============ 提交 / 自动审核态(两种注册对象共用最后一步) ============ */}
{step === steps.length - 1 && (
<div className="mx-auto max-w-lg">
{audit === "auditing" && (
<div className="rounded-2xl bg-card p-10 text-center shadow-sm">
<Loader2 className="mx-auto size-14 animate-spin text-primary" />
<div className="mt-5 text-lg font-bold text-foreground">
{registrant === "supplier" ? "AI 正在审核企业信息..." : "已提交,AI 正在初审..."}
</div>
<div className="mt-2 text-sm text-muted-foreground"> 3~8 </div>
<div className="mt-3 text-[13px] text-muted-foreground/80">     </div>
</div>
)}
{audit === "success" && (
<div className="rounded-2xl bg-card p-10 text-center shadow-sm">
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-[#e8f5e9]">
<CheckCircle2 className="size-14 text-[#4caf50]" />
</div>
<div className="mt-5 text-2xl font-bold text-foreground">🎉 </div>
<p className="mt-2 text-sm text-muted-foreground">
{registrant === "supplier" ? "您的供应商账号已激活,正在自动登录..." : "您的专家账号已创建,请留意激活通知短信/邮件。"}
</p>
<div className="my-5 h-px bg-border" />
<div className="space-y-1.5 text-sm text-muted-foreground">
<div>
{registrant === "supplier" ? "供应商编号" : "专家编号"}
<span className="font-semibold text-foreground">
{registrant === "supplier" ? "SUP_20260709_0158" : "EXP_20260709_002"}
</span>
</div>
<div>
<span className="font-semibold text-foreground">138****8001</span>
</div>
</div>
<div className="mt-5 rounded-lg border border-[#ffcc80] bg-[#fff3e0] px-4 py-2.5 text-xs text-[#e65100]">
📌
</div>
<Button className="mt-6 h-11 w-full bg-brand-gradient hover:opacity-90" onClick={() => navigate("/app")}>
<ArrowRight className="size-4" />
</Button>
</div>
)}
{audit === "rejected" && (
<div className="rounded-2xl bg-card p-10 text-center shadow-sm">
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-[#ffcdd2]">
<XCircle className="size-14 text-[#c62828]" />
</div>
<div className="mt-5 text-2xl font-bold text-[#c62828]"></div>
<div className="mt-5 rounded-lg border bg-[#fafafa] p-4 text-left text-[13px] text-[#c62828]">
<div> </div>
<div> </div>
<div className="mt-2 text-[#666]">💡 </div>
</div>
<Button
className="mt-6 h-11 w-full bg-brand-gradient hover:opacity-90"
onClick={() => {
setAudit("idle");
setStep(0);
}}
>
</Button>
</div>
)}
</div>
)}
{/* 底部导航按钮 */}
{audit === "idle" && (
<div className="mx-auto mt-6 flex max-w-5xl items-center justify-end gap-3">
{step > 0 && (
<Button variant="outline" className="h-11 px-6" onClick={() => setStep((s) => s - 1)}>
</Button>
)}
{isLastFormStep ? (
<Button className="h-11 bg-brand-gradient px-8 hover:opacity-90" onClick={submitAudit}>
</Button>
) : (
<Button className="h-11 bg-brand-gradient px-8 hover:opacity-90" onClick={() => setStep((s) => s + 1)}>
<ArrowRight className="size-4" />
</Button>
)}
</div>
)}
</div>
</div>
);
}