feat(front-end): 依据《详细设计》文档对前端 UI 进行全面升级

- 设计系统:品牌主色统一为品红渐变 #c2185b→#e91e63,新增品牌令牌与呼吸/入场动画
- 登录页重建:内部员工/外部账号切换、账号/手机验证码 Tab、SRM 存量提示
- 注册页重建为多步向导:企业信息→联系人→AI 审核,OCR 状态、密码复杂度校验与强度条
- 小滨 AI 助手重建:品牌渐变悬浮球+呼吸光环,角色感知主动提醒(紧急/AI完成/待办/风险/建议)
- 新增公开门户首页 /portal:Banner 轮播 + 三列公告区 + 免责声明
- 各角色主题色对齐文档:供应商=红、评标专家=蓝、招标专员=绿

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-09 23:46:04 +08:00
parent f7a49ffb78
commit 787de5aa88
16 changed files with 1301 additions and 244 deletions
+15
View File
@@ -0,0 +1,15 @@
# 依赖
node_modules/
**/node_modules/
# 构建产物
dist/
**/dist/
.vite/
**/.vite/
# 系统 / 编辑器
.DS_Store
*.log
.idea/
.vscode/
-16
View File
@@ -5667,22 +5667,6 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+2
View File
@@ -4,6 +4,7 @@ import { RoleProvider } from "./role-context";
import { AppLayout } from "./components/layout/AppLayout";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { PortalHome } from "./pages/PortalHome";
import { Workbench } from "./pages/Workbench";
import { Delegation } from "./pages/Delegation";
import { ProjectList } from "./pages/ProjectList";
@@ -39,6 +40,7 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/portal" element={<PortalHome />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/app" element={<AppLayout />}>
<Route index element={<Workbench />} />
+160 -46
View File
@@ -1,20 +1,63 @@
import { useState, useRef, useEffect } from "react";
import { Sparkles, X, Send, Bot, Lock } from "lucide-react";
import { useNavigate } from "react-router";
import { X, Send, Minus, Lock, ArrowRight } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "./ui/utils";
import { useRole } from "../role-context";
import type { RoleId } from "../roles";
interface Msg {
role: "bot" | "user";
text: string;
}
const quickReplies = [
"如何发起招标委托?",
"邀请招标和公开招标有什么区别?",
"在线评标怎么打分?",
"供应商如何入库?",
];
type ProactiveType = "urgent" | "ai" | "todo" | "risk" | "suggestion";
interface Proactive {
type: ProactiveType;
title: string;
content: string;
to?: string;
actionLabel?: string;
}
const TYPE_STYLE: Record<ProactiveType, { bg: string; border: string; title: string; badge: string }> = {
urgent: { bg: "bg-[#fff3e0]", border: "border-[#ffcc80]", title: "text-[#e65100]", badge: "🔥" },
ai: { bg: "bg-[#e8f5e9]", border: "border-[#a5d6a7]", title: "text-[#2e7d32]", badge: "✅" },
todo: { bg: "bg-[#e3f2fd]", border: "border-[#90caf9]", title: "text-[#0d47a1]", badge: "📋" },
risk: { bg: "bg-[#ffebee]", border: "border-[#ef9a9a]", title: "text-[#c62828]", badge: "⚠️" },
suggestion: { bg: "bg-[#f5f5f5]", border: "border-[#e0e0e0]", title: "text-[#455a64]", badge: "💡" },
};
// 各角色的主动提醒(最多 3~4 条,按优先级)
const PROACTIVE: Partial<Record<RoleId, Proactive[]>> = {
specialist: [
{ type: "urgent", title: "BID-2026-005 今日需开标", content: "“2026年度防腐保温工程框架”已到开标时间,请立即组织评标", to: "/app/projects", actionLabel: "去处理" },
{ type: "ai", title: "AI 已完成:寻源报告生成", content: "“年产5万吨聚丙烯项目”已自动寻源,匹配到 12 家潜在供应商", to: "/app/projects", actionLabel: "查看" },
{ type: "todo", title: "您有 3 个委托单待审核", content: "来自技术部、采购部的委托申请已提交,请及时审核确认", to: "/app/delegation", actionLabel: "去审核" },
],
supplier: [
{ type: "urgent", title: "报名即将截止", content: "“年产5万吨聚丙烯项目”报名将于 7月15日截止,请尽快完成报名", to: "/app/bidding", actionLabel: "去报名" },
{ type: "todo", title: "2 笔保证金待缴纳", content: "有 2 个项目需缴纳投标保证金,逾期将影响投标资格", to: "/app/bidding", actionLabel: "去缴纳" },
{ type: "ai", title: "开标结果已公布", content: "“数字化交付平台”项目已完成开标,点击查看评标结果", to: "/app/bidding", actionLabel: "查看" },
],
expert: [
{ type: "urgent", title: "您收到催化剂采购项目的评标邀请", content: "请查看详情,接受邀请或申请回避(剩余不足 24 小时)", to: "/app/expert-tasks", actionLabel: "去查看" },
{ type: "todo", title: "《评标纪律承诺书》待签署", content: "进入评审前需先签署当前项目的评标纪律承诺书", to: "/app/expert-tasks", actionLabel: "去签署" },
{ type: "suggestion", title: "3 个评审项目待完成", content: "建议优先处理即将截止的评审任务,避免影响评审资格", to: "/app/expert-tasks", actionLabel: "查看" },
],
approver: [
{ type: "todo", title: "待审批事项 2 项", content: "有 2 个定标 / 立项审批等待您处理", to: "/app/approvals", actionLabel: "去审批" },
],
committee: [
{ type: "risk", title: "异常决策待处理", content: "检测到 1 个项目触发异常上报,需领导小组决策", to: "/app/exceptions", actionLabel: "去处理" },
],
auditor: [
{ type: "risk", title: "围串标风险提示", content: "AI 检测到 1 组投标文件相似度偏高,建议核查", to: "/app/collusion", actionLabel: "去核查" },
],
};
const quickReplies = ["如何发起招标委托?", "公开招标和邀请招标的区别?", "在线评标怎么打分?", "供应商如何入库?"];
const knowledge: Record<string, string> = {
:
@@ -26,17 +69,20 @@ const knowledge: Record<string, string> = {
:
"在「供应商管理」页点击「发起入库流程」,或在短名单管理中对供应商点击「发起入库」,系统将自动同步工商风险信息并进入审批。",
:
"您好,当系统中存在同类型未完成立项的委托时,系统会自动推送合并建议。您可以在立项阶段的「合并招标建议」卡片中查看并确认合并。需要我为您演示具体步骤吗?",
"当系统中存在同类型未完成立项的委托时,系统会自动推送合并建议。您可以在立项阶段的「合并招标建议」卡片中查看并确认合并。",
:
"进入「投标中心」→ 选择项目 → 在「上传投标文件」区域上传文件,系统会自动加密并上链存证。开标前任何人都无法查看,只有您本人可查看和修改,投标截止前还可撤回重传。",
"进入「投标中心」→ 选择项目 → 在「上传投标文件」区域上传文件,系统会自动加密并上链存证。开标前任何人都无法查看,投标截止前还可撤回重传。",
};
const dataKeywords = ["报价", "投标价", "多少钱", "金额", "中标", "得分", "分数", "名单", "谁"];
export function XiaobinAssistant() {
const { sensitive } = useRole();
const { sensitive, role, roleId } = useRole();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [showChat, setShowChat] = useState(false);
const [input, setInput] = useState("");
const [proactive, setProactive] = useState<Proactive[]>(PROACTIVE[roleId] ?? []);
const [msgs, setMsgs] = useState<Msg[]>([
{
role: "bot",
@@ -45,14 +91,21 @@ export function XiaobinAssistant() {
]);
const endRef = useRef<HTMLDivElement>(null);
// 切换角色时刷新主动提醒
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, open]);
setProactive(PROACTIVE[roleId] ?? []);
}, [roleId]);
useEffect(() => {
if (showChat) endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, showChat]);
const gradient = `linear-gradient(135deg, ${role.bannerFrom}, ${role.bannerTo})`;
const unread = proactive.length;
function answer(q: string) {
// 流程敏感期:拒答数据类问题
if (sensitive && dataKeywords.some((k) => q.includes(k))) {
return "当前为教学模式(流程敏感期),数据类问题暂无法回答。暂未到查看时间,请等待系统通知。我可以继续为您讲解系统操作哦~";
return "当前为流程敏感期(如开标前),数据类问题暂无法回答。请等待系统通知。我可以继续为您讲解系统操作哦~";
}
const key = Object.keys(knowledge).find((k) => q.includes(k));
return key
@@ -63,51 +116,124 @@ export function XiaobinAssistant() {
function send(text: string) {
const q = text.trim();
if (!q) return;
setShowChat(true);
setMsgs((m) => [...m, { role: "user", text: q }]);
setInput("");
setTimeout(() => {
setMsgs((m) => [...m, { role: "bot", text: answer(q) }]);
}, 500);
setTimeout(() => setMsgs((m) => [...m, { role: "bot", text: answer(q) }]), 500);
}
function handleAction(p: Proactive) {
if (p.to) navigate(p.to);
setOpen(false);
}
return (
<>
{/* 悬浮球 + 呼吸光环 */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-gradient-to-r from-violet-500 to-blue-600 px-4 py-3 text-white shadow-lg shadow-blue-500/30 transition-transform hover:scale-105"
className="group fixed bottom-8 right-8 z-50 flex size-[68px] items-center justify-center rounded-full text-white shadow-xl transition-transform hover:scale-105"
style={{ backgroundImage: gradient }}
aria-label="打开小滨 AI 助手"
>
<Sparkles className="size-5" />
<span className="text-sm font-medium"></span>
<span className="pointer-events-none absolute inset-0 rounded-full animate-breathe" style={{ backgroundImage: gradient }} />
<span className="relative flex flex-col items-center leading-none">
<span className="text-sm font-bold"></span>
<span className="mt-0.5 text-[9px] text-white/90">AI </span>
</span>
{unread > 0 && (
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-[#f44336] text-[10px] font-bold text-white">
{unread}
</span>
)}
</button>
)}
{open && (
<div className="fixed bottom-6 right-6 z-50 flex h-[540px] w-[380px] flex-col overflow-hidden rounded-2xl border bg-card shadow-2xl">
<div className="flex items-center justify-between bg-gradient-to-r from-violet-500 to-blue-600 px-4 py-3 text-white">
<div className="fixed bottom-8 right-8 z-50 flex h-[520px] w-[360px] flex-col overflow-hidden rounded-2xl border bg-card shadow-2xl">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 text-white" style={{ backgroundImage: gradient }}>
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-full bg-white/20">
<Bot className="size-5" />
</div>
<div className="flex size-9 items-center justify-center rounded-full bg-white/20 text-sm font-bold"></div>
<div className="leading-tight">
<div className="text-sm font-semibold"> · </div>
<div className="flex items-center gap-1 text-[11px] text-white/80">
<span className="size-1.5 rounded-full bg-emerald-300" /> 线 · &
<div className="text-sm font-semibold"> AI </div>
<div className="flex items-center gap-1 text-[11px] text-white/85">
<span className="size-1.5 rounded-full bg-emerald-300" /> 线 ·
</div>
</div>
</div>
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15">
<div className="flex items-center gap-1">
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15" title="最小化">
<Minus className="size-4" />
</button>
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15" title="关闭">
<X className="size-4" />
</button>
</div>
</div>
{sensitive && (
<div className="flex items-center gap-1.5 border-b bg-amber-50 px-4 py-2 text-xs text-amber-700">
<Lock className="size-3.5" />
<Lock className="size-3.5" />
</div>
)}
<div className="flex-1 space-y-3 overflow-y-auto bg-muted/30 p-4">
{!showChat ? (
<>
{proactive.length === 0 && (
<div className="rounded-xl border bg-card p-4 text-center text-sm text-muted-foreground">
🎉
</div>
)}
{proactive.map((p, i) => {
const st = TYPE_STYLE[p.type];
return (
<div key={i} className={cn("animate-slide-up rounded-xl border p-3", st.bg, st.border)}>
<div className="flex items-start justify-between gap-2">
<div className={cn("text-[13px] font-bold", st.title)}>
{st.badge} {p.title}
</div>
<button
onClick={() => setProactive((ps) => ps.filter((_, idx) => idx !== i))}
className="text-muted-foreground/60 hover:text-muted-foreground"
>
<X className="size-3.5" />
</button>
</div>
<p className="mt-1 text-xs leading-relaxed text-[#4a5568]">{p.content}</p>
{p.to && (
<button
onClick={() => handleAction(p)}
className={cn("mt-2 inline-flex items-center gap-0.5 text-xs font-bold underline", st.title)}
>
{p.actionLabel ?? "去处理"} <ArrowRight className="size-3" />
</button>
)}
</div>
);
})}
<div className="pt-1">
<p className="mb-2 text-xs text-muted-foreground"></p>
<div className="space-y-2">
{quickReplies.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="block w-full rounded-lg border bg-card px-3 py-2 text-left text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
>
{q}
</button>
))}
</div>
</div>
</>
) : (
<>
<button onClick={() => setShowChat(false)} className="text-xs text-primary hover:underline">
</button>
{msgs.map((m, i) => (
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
<div
@@ -122,21 +248,9 @@ export function XiaobinAssistant() {
</div>
</div>
))}
{msgs.length <= 1 && (
<div className="space-y-2 pt-2">
<p className="text-xs text-muted-foreground">:</p>
{quickReplies.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="block w-full rounded-lg border bg-card px-3 py-2 text-left text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
>
{q}
</button>
))}
</div>
)}
<div ref={endRef} />
</>
)}
</div>
<div className="flex items-center gap-2 border-t bg-card p-3">
@@ -145,9 +259,9 @@ export function XiaobinAssistant() {
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send(input)}
placeholder="输入您的问题…"
className="h-9 flex-1 rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary"
className="h-9 flex-1 rounded-full border bg-input-background px-3.5 text-sm outline-none focus:border-primary"
/>
<Button size="icon" onClick={() => send(input)} className="size-9 shrink-0">
<Button size="icon" onClick={() => send(input)} className="size-9 shrink-0 rounded-full" style={{ backgroundImage: gradient }}>
<Send className="size-4" />
</Button>
</div>
@@ -8,7 +8,7 @@ export function AiBadge({ label = "AI 生成", className }: { label?: string; cl
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md bg-gradient-to-r from-violet-500 to-blue-500 px-2 py-0.5 text-xs font-medium text-white",
"inline-flex items-center gap-1 rounded-md bg-brand-gradient px-2 py-0.5 text-xs font-medium text-white",
className,
)}
>
+1 -1
View File
@@ -96,7 +96,7 @@ function ApprovalDetail({ item, onBack }: { item: ApprovalItem; onBack: () => vo
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2 space-y-6">
{/* AI 审批摘要 */}
<Card className="gap-0 border-violet-200 bg-gradient-to-br from-violet-50 to-blue-50 p-6">
<Card className="gap-0 border-primary/20 bg-secondary/50 p-6">
<div className="mb-2 flex items-center gap-2">
<Sparkles className="size-4 text-violet-500" />
<h3 className="text-foreground">AI </h3>
+1 -1
View File
@@ -165,7 +165,7 @@ export function Delegation() {
</div>
</Card>
<Card className="gap-0 bg-gradient-to-br from-violet-50 to-blue-50 p-6">
<Card className="gap-0 border-primary/20 bg-secondary/50 p-6">
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-violet-500" />
<h4 className="text-foreground">AI </h4>
@@ -106,7 +106,7 @@ function Detail({ item, onBack }: { item: Exception; onBack: () => void }) {
<Card className="gap-0 p-6">
<h3 className="mb-2 text-foreground"></h3>
<p className="text-sm leading-relaxed text-muted-foreground">{item.desc}</p>
<div className="mt-4 rounded-lg border border-violet-200 bg-gradient-to-br from-violet-50 to-blue-50 p-4">
<div className="mt-4 rounded-lg border border-primary/20 bg-secondary/50 p-4">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Sparkles className="size-4 text-violet-500" /> AI
</div>
+189 -44
View File
@@ -1,119 +1,246 @@
import { useState } from "react";
import { useNavigate, Link } from "react-router";
import { Hexagon, User, Lock, ShieldCheck, Sparkles, ArrowRight } from "lucide-react";
import { Hexagon, User, Lock, Smartphone, ShieldCheck, ArrowRight, Eye, EyeOff, Info } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { cn } from "../components/ui/utils";
type Mode = "employee" | "vendor";
type Tab = "account" | "mobile";
export function LoginPage() {
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>("vendor");
const [tab, setTab] = useState<Tab>("account");
const [account, setAccount] = useState("zhangwei");
const [pwd, setPwd] = useState("123456");
const [showPwd, setShowPwd] = useState(false);
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [remember, setRemember] = useState(true);
const [countdown, setCountdown] = useState(0);
function sendCode() {
if (!/^1[3-9]\d{9}$/.test(phone)) {
toast.error("请输入正确的手机号");
return;
}
toast.success("验证码已发送,请注意查收");
setCountdown(60);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
}
function submit(e: React.FormEvent) {
e.preventDefault();
if (mode === "employee") {
toast.info("正在跳转至集团 IAM 统一身份认证…");
setTimeout(() => navigate("/app"), 800);
return;
}
navigate("/app");
}
return (
<div className="flex h-screen w-full overflow-hidden bg-slate-50">
<div className="flex h-screen w-full overflow-hidden bg-[#f5f7fa]">
{/* 左侧品牌区 */}
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-gradient-to-br from-[#0f1e3d] via-[#13306b] to-[#2563eb] p-12 text-white lg:flex">
<div className="flex items-center gap-3">
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-brand-gradient p-12 text-white lg:flex">
<Link to="/portal" className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
<Hexagon className="size-6" />
</div>
<div>
<div className="text-lg font-semibold"></div>
<div className="text-sm text-white/60">Binhua Smart Tendering Platform</div>
</div>
<div className="text-sm text-white/70">Smart Tendering Platform</div>
</div>
</Link>
<div className="relative z-10">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-sm backdrop-blur">
<Sparkles className="size-4" /> AI · 线
</div>
<h1 className="text-white" style={{ fontSize: 40, lineHeight: 1.2, fontWeight: 700 }}>
AI
<h1 className="text-white" style={{ fontSize: 44, lineHeight: 1.2, fontWeight: 700, letterSpacing: 2 }}>
</h1>
<p className="mt-4 max-w-md text-white/70">
<p className="mt-5 max-w-md text-white/80">
AI · 线 ·
</p>
<p className="mt-2 max-w-md text-sm text-white/60">
· · ·
</p>
<div className="mt-8 grid grid-cols-3 gap-4">
{[
{ k: "全流程", v: "9 大阶段闭环" },
{ k: "AI 预填", v: "委托效率 +60%" },
{ k: "AI 寻源", v: "委托效率 +60%" },
{ k: "链上存证", v: "投标哈希上链" },
].map((s) => (
<div key={s.k} className="rounded-xl bg-white/10 p-4 backdrop-blur">
<div className="text-lg font-semibold">{s.k}</div>
<div className="mt-1 text-xs text-white/60">{s.v}</div>
<div className="mt-1 text-xs text-white/70">{s.v}</div>
</div>
))}
</div>
</div>
<div className="text-xs text-white/40">© 2026 · </div>
<div className="text-xs text-white/50">© 2026 V1.0</div>
{/* 装饰 */}
<div className="pointer-events-none absolute -right-20 top-1/4 size-72 rounded-full bg-blue-400/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-10 right-1/3 size-60 rounded-full bg-violet-400/20 blur-3xl" />
<div className="pointer-events-none absolute -right-24 top-1/4 size-80 rounded-full bg-white/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-16 right-1/3 size-60 rounded-full bg-white/10 blur-3xl" />
<div className="pointer-events-none absolute left-1/2 top-1/2 size-72 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10" />
<div className="pointer-events-none absolute left-1/2 top-1/2 size-52 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10" />
</div>
{/* 右侧表单区 */}
<div className="flex flex-1 items-center justify-center p-8">
<div className="w-full max-w-sm">
<div className="mb-8 lg:hidden">
<div className="w-full max-w-[400px]">
<div className="mb-6 lg:hidden">
<div className="flex items-center gap-2">
<Hexagon className="size-7 text-primary" />
<span className="font-semibold"></span>
</div>
</div>
<h2 className="text-foreground"></h2>
<p className="mt-1 text-sm text-muted-foreground"></p>
<div className="text-center">
<h2 className="text-foreground" style={{ fontSize: 24 }}></h2>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<form
className="mt-8 space-y-4"
onSubmit={(e) => {
e.preventDefault();
navigate("/app");
}}
{/* 角色切换开关: 内部员工 / 外部账号 */}
<div className="mt-6 flex rounded-full bg-muted p-1">
{(
[
{ id: "employee" as Mode, label: "内部员工" },
{ id: "vendor" as Mode, label: "外部账号" },
]
).map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={cn(
"flex-1 rounded-full py-2 text-sm font-medium transition-all",
mode === m.id ? "bg-brand-gradient text-white shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{m.label}
</button>
))}
</div>
<p className="mt-2 text-center text-xs text-primary">
{mode === "employee" ? "当前:内部员工模式(走 IAM 集团统一认证)" : "当前:外部账号模式(走平台账号 / SRM 认证)"}
</p>
{/* Tab: 账号登录 / 手机验证码 */}
<div className="mt-5 flex border-b border-border">
{(
[
{ id: "account" as Tab, label: "账号登录" },
{ id: "mobile" as Tab, label: "手机验证码" },
]
).map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
"relative -mb-px px-4 pb-2.5 text-sm transition-colors",
tab === t.id ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
{tab === t.id && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-brand-gradient" />}
</button>
))}
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
{tab === "account" ? (
<>
<div>
<label className="mb-1.5 block text-sm"></label>
<label className="mb-1.5 block text-sm text-[#4a5568]"> / </label>
<div className="relative">
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={account}
onChange={(e) => setAccount(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none focus:border-primary"
placeholder="请输入账号"
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-primary"
placeholder="请输入您的账号"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm"></label>
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type="password"
type={showPwd ? "text" : "password"}
value={pwd}
onChange={(e) => setPwd(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none focus:border-primary"
placeholder="请输入密码"
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-10 text-sm outline-none transition-colors focus:border-primary"
placeholder="请输入您的密码"
/>
<button
type="button"
onClick={() => setShowPwd((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPwd ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
</>
) : (
<>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<div className="relative">
<Smartphone className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-primary"
placeholder="请输入11位手机号"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<div className="flex gap-3">
<div className="relative flex-1">
<ShieldCheck className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none focus:border-primary"
placeholder="验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-primary"
placeholder="6 位验证码"
/>
</div>
<div className="flex h-11 w-28 items-center justify-center rounded-lg bg-gradient-to-r from-blue-100 to-violet-100 font-mono tracking-widest text-primary select-none">
8 K 2 7
<button
type="button"
onClick={sendCode}
disabled={countdown > 0}
className="h-11 w-28 shrink-0 rounded-lg border border-primary text-sm font-medium text-primary transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:border-border disabled:text-muted-foreground"
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</button>
</div>
</div>
</>
)}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 text-muted-foreground">
<input type="checkbox" className="size-4 rounded border-border accent-[var(--primary)]" defaultChecked />
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="size-4 rounded border-border accent-[var(--primary)]"
/>
</label>
<button type="button" className="text-primary hover:underline">
@@ -121,16 +248,34 @@ export function LoginPage() {
</button>
</div>
<Button type="submit" className="h-11 w-full">
<ArrowRight className="size-4" />
<Button type="submit" className="h-11 w-full bg-brand-gradient text-base hover:opacity-90">
<ArrowRight className="size-4" />
</Button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
<div className="mt-5 text-center text-sm text-muted-foreground">
{mode === "employee" ? (
<span className="ml-1 text-muted-foreground/70"> HR </span>
) : (
<Link to="/register" className="ml-1 font-medium text-primary hover:underline">
</Link>
)}
</div>
{/* SRM 温馨提示 */}
{mode === "vendor" && (
<div className="mt-5 flex items-start gap-2 rounded-lg border border-[#ffcc80] bg-[#fff3e0] px-3 py-2.5 text-xs text-[#e65100]">
<Info className="mt-0.5 size-4 shrink-0" />
<span> SRM 使 SRM </span>
</div>
)}
<div className="mt-6 text-center">
<Link to="/portal" className="text-xs text-muted-foreground hover:text-primary">
</Link>
</div>
</div>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ export function MergeBidding() {
<PageHeader title="合并招标" description="AI 检测同类型未完成立项的委托,给出合并建议与效益估算" />
{/* 提示横幅 */}
<div className="mb-6 flex items-center gap-3 rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-blue-50 p-4">
<div className="mb-6 flex items-center gap-3 rounded-xl border border-primary/20 bg-secondary/50 p-4">
<Sparkles className="size-5 text-violet-500" />
<p className="text-sm text-foreground">
<span className="font-semibold">{mergeCandidates.length}</span>
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import { Hexagon, ArrowRight, Sparkles, Bot } from "lucide-react";
import { cn } from "../components/ui/utils";
const NAV = ["首页", "招标公告", "定标公告", "企业新闻"];
const BANNERS = [
{
title: "AI 赋能的智慧招标",
subtitle: "全流程线上闭环,主动智能寻源,自动风险评估",
tags: ["智能招标委托", "供应商 AI 寻源", "自动化评标辅助"],
},
{
title: "一次注册,双平台通用",
subtitle: "自动同步 SRM,建立统一供应商身份,秒级 AI 审核即时生效",
tags: ["营业执照 OCR", "工商实时验真", "SRM 无感登录"],
},
{
title: "阳光招采,全程存证",
subtitle: "投标文件加密上链,评审操作哈希存证,围串标智能预警",
tags: ["链上存证", "盲评脱敏", "AI 雷同检测"],
},
];
const TENDERS = [
{ title: "年产5万吨聚丙烯项目设备采购", meta: "2026-07-01 货物", status: "报名中", color: "#2e7d32", dot: "#4caf50" },
{ title: "数字化交付平台开发服务", meta: "2026-06-29 服务", status: "即将截止", color: "#e65100", dot: "#ff9800" },
{ title: "2026年度防腐保温工程框架", meta: "2026-06-28 工程", status: "已截止", color: "#9e9e9e", dot: "#bdbdbd" },
];
const AWARDS = [
{ title: "催化剂采购项目(包1", meta: "2026-07-01 中标方:滨化催化剂有限公司" },
{ title: "厂区安防监控升级项目", meta: "2026-06-30 中标方:海康威视" },
{ title: "办公楼装修工程", meta: "2026-06-28 中标方:滨州建工集团" },
];
const NEWS = [
{ title: "滨化集团获评“省级智能工厂”", meta: "2026-07-01 集团动态" },
{ title: "2026 年供应商大会圆满举办", meta: "2026-06-25 合作伙伴" },
{ title: "智慧招标平台正式上线运营", meta: "2026-06-01 平台公告" },
];
function AnnouncementCard({
title,
tag,
headerColor,
items,
moreBg,
moreText,
}: {
title: string;
tag: string;
headerColor: string;
items: { title: string; meta: string; status?: string; color?: string; dot?: string }[];
moreBg: string;
moreText: string;
}) {
return (
<div className="flex flex-col overflow-hidden rounded-2xl bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06)]">
<div className="px-5 py-3 text-white" style={{ backgroundColor: headerColor }}>
<div className="text-base font-bold">{title}</div>
<div className="text-[11px] text-white/80">{tag}</div>
</div>
<div className="flex-1 space-y-2 p-4">
{items.map((it) => (
<div key={it.title} className="cursor-pointer rounded-lg bg-[#fafafa] px-3 py-2.5 transition-colors hover:bg-[#f0f2f7]">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm text-[#1a2332]">{it.title}</span>
{it.status && (
<span className="flex shrink-0 items-center gap-1 text-xs" style={{ color: it.color }}>
<span className="size-1.5 rounded-full" style={{ backgroundColor: it.dot }} />
{it.status}
</span>
)}
</div>
<div className="mt-1 text-xs text-[#8895aa]">{it.meta}</div>
</div>
))}
</div>
<div className="px-4 pb-4">
<button
className="w-full rounded-full py-1.5 text-xs font-bold transition-opacity hover:opacity-80"
style={{ backgroundColor: moreBg, color: moreText }}
>
</button>
</div>
</div>
);
}
export function PortalHome() {
const navigate = useNavigate();
const [banner, setBanner] = useState(0);
const [activeNav, setActiveNav] = useState(0);
useEffect(() => {
const t = setInterval(() => setBanner((b) => (b + 1) % BANNERS.length), 5000);
return () => clearInterval(t);
}, []);
const b = BANNERS[banner];
return (
<div className="min-h-screen bg-[#f5f7fa]">
{/* 顶部导航 */}
<header className="sticky top-0 z-30 bg-brand-gradient shadow-sm">
<div className="mx-auto flex h-[68px] max-w-[1200px] items-center px-8">
<Link to="/portal" className="flex items-baseline gap-2 text-white">
<Hexagon className="size-6 self-center" />
<span className="text-xl font-bold"></span>
<span className="hidden text-xs text-white/70 sm:inline">Smart Tendering Platform</span>
</Link>
<nav className="ml-12 hidden gap-1 md:flex">
{NAV.map((n, i) => (
<button
key={n}
onClick={() => setActiveNav(i)}
className={cn(
"group relative px-4 py-2 text-sm text-white/80 transition-colors hover:text-white",
activeNav === i && "font-bold text-white",
)}
>
{n}
<span
className={cn(
"absolute inset-x-4 -bottom-0.5 h-0.5 rounded-full bg-white transition-all",
activeNav === i ? "opacity-100" : "opacity-0 group-hover:opacity-60",
)}
/>
</button>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
<button
onClick={() => navigate("/")}
className="rounded-lg border border-white/60 px-4 py-1.5 text-sm text-white transition-colors hover:bg-white/15"
>
</button>
<button
onClick={() => navigate("/register")}
className="rounded-lg bg-white px-4 py-1.5 text-sm font-bold text-primary transition-transform hover:-translate-y-0.5"
>
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-[1200px] px-8 pb-16">
{/* Banner 轮播 */}
<section className="relative mt-6 overflow-hidden rounded-2xl bg-gradient-to-br from-[#fce4ec] to-[#f3e5f5] shadow-[0_6px_24px_rgba(0,0,0,0.08)]">
<div className="flex min-h-[280px] flex-col justify-center px-12 py-10">
<div className="mb-3 inline-flex w-fit items-center gap-1.5 rounded-full bg-white/70 px-3 py-1 text-xs text-primary">
<Sparkles className="size-3.5" /> AI · 线
</div>
<h1 key={banner} className="animate-slide-up text-primary" style={{ fontSize: 34, fontWeight: 700 }}>
{b.title}
</h1>
<p className="mt-3 text-lg text-[#4a5568]">{b.subtitle}</p>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm text-[#8895aa]">
{b.tags.map((t) => (
<span key={t}> {t}</span>
))}
</div>
<button
onClick={() => navigate("/register")}
className="mt-6 inline-flex w-fit items-center gap-1.5 rounded-lg bg-brand-gradient px-6 py-2.5 text-sm font-bold text-white shadow-md transition-transform hover:scale-105"
>
<ArrowRight className="size-4" />
</button>
</div>
{/* 指示器 */}
<div className="absolute bottom-6 right-12 flex gap-2">
{BANNERS.map((_, i) => (
<button
key={i}
aria-label={`切换到第 ${i + 1}`}
onClick={() => setBanner(i)}
className={cn("h-2 rounded-full transition-all", i === banner ? "w-6 bg-primary" : "w-2 bg-[#d1d5db]")}
/>
))}
</div>
</section>
{/* 公告区 */}
<section className="mt-8">
<h2 className="mb-4 flex items-center gap-2 text-[#1a2332]" style={{ fontSize: 20, fontWeight: 700 }}>
📢
</h2>
<div className="grid gap-5 md:grid-cols-3">
<AnnouncementCard title="招标公告" tag="最新发布" headerColor="#c2185b" items={TENDERS} moreBg="#fce4ec" moreText="#c2185b" />
<AnnouncementCard title="定标公告" tag="最新定标" headerColor="#1565c0" items={AWARDS} moreBg="#e3f2fd" moreText="#1565c0" />
<AnnouncementCard title="企业新闻" tag="集团动态" headerColor="#e65100" items={NEWS} moreBg="#fff3e0" moreText="#e65100" />
</div>
</section>
{/* 免责声明 */}
<section className="mt-8 rounded-2xl bg-white p-6 text-center shadow-[0_4px_16px_rgba(0,0,0,0.06)]">
<div className="text-sm font-semibold text-[#4a5568]"></div>
<p className="mx-auto mt-2 max-w-3xl text-xs leading-relaxed text-[#8895aa]">
</p>
<p className="mt-2 text-xs text-[#8895aa]"> © 2026 ICP备XXXXXXXX号</p>
</section>
</main>
{/* AI 助手浮标 */}
<button
onClick={() => navigate("/")}
className="group fixed bottom-8 right-8 z-50 flex size-[68px] items-center justify-center rounded-full bg-brand-gradient text-white shadow-xl transition-transform hover:scale-105"
aria-label="小滨 AI 助手"
>
<span className="pointer-events-none absolute inset-0 rounded-full bg-brand-gradient animate-breathe" />
<span className="relative flex flex-col items-center leading-none">
<Bot className="size-5" />
<span className="mt-0.5 text-[9px] text-white/90"></span>
</span>
</button>
</div>
);
}
+1 -1
View File
@@ -123,7 +123,7 @@ export function ProjectDetail() {
{ t: "推荐评标办法", d: "综合评估法:技术 60 分 / 商务 20 分 / 价格 20 分,技术权重较高。" },
{ t: "推荐供应商短名单", d: "基于合格供方库与历史业绩,推荐浙江中控、和利时、西门子等 3 家。" },
].map((c) => (
<div key={c.t} className="rounded-xl border bg-gradient-to-br from-violet-50/60 to-blue-50/60 p-4">
<div key={c.t} className="rounded-xl border border-primary/15 bg-secondary/40 p-4">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Sparkles className="size-3.5 text-violet-500" /> {c.t}
</div>
+560 -50
View File
@@ -1,81 +1,591 @@
import { useMemo, useState } from "react";
import { useNavigate, Link } from "react-router";
import { Hexagon, ArrowLeft, CheckCircle2 } from "lucide-react";
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";
const fields = [
{ label: "企业全称", placeholder: "请输入营业执照上的企业全称", col: 2 },
{ label: "统一社会信用代码", placeholder: "18 位信用代码" },
{ label: "企业类型", placeholder: "如:有限责任公司" },
{ label: "法定代表人", placeholder: "请输入法人姓名" },
{ label: "注册资本(万元)", placeholder: "请输入注册资本" },
{ label: "联系人", placeholder: "请输入联系人姓名" },
{ label: "联系电话", placeholder: "请输入手机号码" },
{ label: "电子邮箱", placeholder: "用于接收平台通知" },
{ label: "经营类别", placeholder: "如:自动化控制 / 环保工程" },
{ label: "企业地址", placeholder: "请输入详细注册地址", col: 2 },
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-slate-50">
<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">
<Hexagon className="size-6 text-primary" />
<span className="font-semibold"> · </span>
<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-3xl px-6 py-10">
<div className="mb-6">
<h1 className="text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground">
1-3
</p>
<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="rounded-xl border bg-card p-8">
<h3 className="mb-5 text-foreground"></h3>
<form
className="grid grid-cols-2 gap-5"
onSubmit={(e) => {
e.preventDefault();
toast.success("注册申请已提交,平台正在进行资质审核");
setTimeout(() => navigate("/"), 1200);
}}
<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"
>
{fields.map((f) => (
<div key={f.label} className={f.col === 2 ? "col-span-2" : ""}>
<label className="mb-1.5 block text-sm">{f.label}</label>
<input
className="h-10 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary"
placeholder={f.placeholder}
<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"> / </label>
<div className="flex h-28 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 text-sm text-muted-foreground hover:border-primary">
<CheckCircle2 className="mb-2 size-6 text-muted-foreground/60" />
PDF / JPG / PNG
<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="col-span-2 mt-2 flex items-center gap-3">
<Button type="submit" className="h-11 flex-1">
</Button>
<Button type="button" variant="outline" className="h-11" onClick={() => navigate("/")}>
{/* 密码复杂度要求 */}
<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>
</form>
)}
{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>
);
+5 -5
View File
@@ -122,9 +122,9 @@ function RoleExtra({ extra }: { extra: string }) {
if (extra === "specialist") {
return (
<Card className="gap-0 border-violet-200 bg-gradient-to-br from-violet-50 to-blue-50 p-5">
<Card className="gap-0 border-primary/20 bg-secondary/50 p-5">
<div className="mb-2 flex items-center gap-2">
<GitMerge className="size-4 text-violet-500" />
<GitMerge className="size-4 text-primary" />
<h4 className="text-foreground"></h4>
<AiBadge label="AI" />
</div>
@@ -135,7 +135,7 @@ function RoleExtra({ extra }: { extra: string }) {
{mergeCandidates.map((m) => (
<div key={m.id} className="flex items-center justify-between rounded-lg border bg-card px-3 py-2 text-sm">
<span className="line-clamp-1 flex-1 text-foreground">{m.name}</span>
<span className="ml-2 shrink-0 text-xs text-violet-600"> {m.overlap}%</span>
<span className="ml-2 shrink-0 text-xs text-primary"> {m.overlap}%</span>
</div>
))}
</div>
@@ -150,7 +150,7 @@ function RoleExtra({ extra }: { extra: string }) {
return (
<Card className="gap-0 p-5">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="size-4 text-violet-500" />
<Sparkles className="size-4 text-primary" />
<h4 className="text-foreground">AI </h4>
</div>
<div className="space-y-2.5">
@@ -164,7 +164,7 @@ function RoleExtra({ extra }: { extra: string }) {
>
<div className="flex items-center justify-between">
<span className="line-clamp-1 flex-1 text-sm text-foreground">{p.name}</span>
<span className="ml-2 shrink-0 rounded bg-violet-50 px-1.5 py-0.5 text-xs text-violet-600">
<span className="ml-2 shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs text-primary">
{p.match}%
</span>
</div>
+9 -9
View File
@@ -48,9 +48,9 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "招标专员",
user: "张伟",
dept: "招标采购中心",
color: "#2563eb",
bannerFrom: "#0f1e3d",
bannerTo: "#2563eb",
color: "#00695c",
bannerFrom: "#00695c",
bannerTo: "#00897b",
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/projects", label: "项目管理", icon: "FolderKanban" },
@@ -82,9 +82,9 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "评标专家",
user: "郑国华",
dept: "外部专家 · 化工工艺",
color: "#f59e0b",
bannerFrom: "#7c2d12",
bannerTo: "#f59e0b",
color: "#1565c0",
bannerFrom: "#0d47a1",
bannerTo: "#1976d2",
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/expert-tasks", label: "评标任务", icon: "Gavel" },
@@ -121,9 +121,9 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "供应商",
user: "浙江中控技术股份有限公司",
dept: "投标用户",
color: "#8b5cf6",
bannerFrom: "#4c1d95",
bannerTo: "#8b5cf6",
color: "#c2185b",
bannerFrom: "#c2185b",
bannerTo: "#e91e63",
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/bidding", label: "投标中心", icon: "PackageOpen" },
+85 -22
View File
@@ -2,43 +2,46 @@
:root {
--font-size: 14px;
--background: #f8fafc;
--foreground: #1e293b;
--background: #f5f7fa;
--foreground: #1a2332;
--card: #ffffff;
--card-foreground: #1e293b;
--card-foreground: #1a2332;
--popover: #ffffff;
--popover-foreground: #1e293b;
--primary: #2563eb;
--popover-foreground: #1a2332;
--primary: #c2185b;
--primary-foreground: #ffffff;
--secondary: #eff6ff;
--secondary-foreground: #1d4ed8;
--secondary: #fce4ec;
--secondary-foreground: #ad1457;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #eff6ff;
--accent-foreground: #1d4ed8;
--accent: #fce4ec;
--accent-foreground: #ad1457;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: transparent;
--input-background: #f8fafc;
--input-background: #f7f8fc;
--switch-background: #cbd5e1;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: #2563eb;
--chart-1: #2563eb;
--chart-2: #0ea5e9;
--chart-3: #14b8a6;
--ring: #c2185b;
--chart-1: #c2185b;
--chart-2: #1565c0;
--chart-3: #00897b;
--chart-4: #f59e0b;
--chart-5: #8b5cf6;
--radius: 0.625rem;
--sidebar: #0f1e3d;
--sidebar-foreground: #cbd5e1;
--sidebar-primary: #2563eb;
--chart-5: #9c27b0;
--radius: 0.75rem;
/* 品牌渐变(全平台统一): #c2185b → #e91e63 */
--brand-from: #c2185b;
--brand-to: #e91e63;
--sidebar: #2d0a19;
--sidebar-foreground: #f4d9e4;
--sidebar-primary: #e91e63;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #1e3a8a;
--sidebar-accent: #6d1338;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #1e293b;
--sidebar-ring: #2563eb;
--sidebar-border: #4a0e26;
--sidebar-ring: #e91e63;
}
.dark {
@@ -117,6 +120,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-brand-from: var(--brand-from);
--color-brand-to: var(--brand-to);
}
@layer base {
@@ -179,3 +184,61 @@
line-height: 1.5;
}
}
/* ===== 平台品牌工具类与动效(依据《详细设计》统一视觉语言) ===== */
@layer utilities {
/* 品牌渐变背景: #c2185b → #e91e63 */
.bg-brand-gradient {
background-image: linear-gradient(135deg, var(--brand-from), var(--brand-to));
}
.text-brand-gradient {
background-image: linear-gradient(135deg, var(--brand-from), var(--brand-to));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
/* AI 助手呼吸光环 */
@keyframes brand-breathe {
0% {
transform: scale(1);
opacity: 0.55;
}
100% {
transform: scale(1.55);
opacity: 0;
}
}
.animate-breathe {
animation: brand-breathe 2s ease-out infinite;
}
/* 列表/卡片入场(自下而上淡入) */
@keyframes brand-slide-up {
0% {
opacity: 0;
transform: translateY(16px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: brand-slide-up 0.5s ease forwards;
}
/* 待办数字变化呼吸提醒 */
@keyframes brand-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.animate-pulse-soft {
animation: brand-pulse 1s ease-in-out 2;
}