Files
STP/front-end/src/app/components/XiaobinAssistant.tsx
T
vame 41fc6e86dd feat(frontend): 对齐万华门户风格并更新全量截图文档
- 新增门户导航子页面(交易信息、政策法规、操作指南、常见问题、采购平台)并统一门户壳层样式
- 按万华站点风格重构首页与登录页(导航、轮播、公告、登录区)
- 优化小滨助手:首页/登录页接入、可拖拽吸边、圆形图标、圆形外框与切换动画优化
- 重新生成全量页面截图并更新《滨化智慧招标平台-前端界面截图汇总.docx》

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

500 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router";
import { X, Send, Minus, Lock, ArrowRight, Sparkles, Bot } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "./ui/utils";
import { useRole } from "../role-context";
import type { RoleId } from "../roles";
interface MsgAction {
label: string;
to: string;
}
interface Msg {
role: "bot" | "user";
text: string;
actions?: MsgAction[];
}
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-[#e3f2fd]", border: "border-[#90caf9]", title: "text-[#0d47a1]", badge: "✅" },
todo: { bg: "bg-[#e8f0fe]", border: "border-[#a3c2f0]", title: "text-[#1565c0]", 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/P2026005/sourcing", actionLabel: "查看结果" },
{ type: "todo", title: "您有 3 个委托单待受理", content: "来自采购需求人员的委托申请已提交,请及时受理并立项。", to: "/app/projects", actionLabel: "去受理" },
],
requester: [
{ type: "ai", title: "阀门采购项目已定标", content: "您的「氯碱车间耐腐蚀阀门采购」已完成定标,下一步请进入 SRM 生成采购合同。", to: "/app/projects", actionLabel: "查看项目" },
{ type: "todo", title: "2 个 AI 预填委托单待确认", content: "AI 已根据采购计划为您预填委托单,确认无误后即可提交立项。", to: "/app/delegation", actionLabel: "去确认" },
],
expert: [
{ type: "urgent", title: "保温材料招标项目评标邀请", content: "您有一个「保温材料招标项目」的评标邀请,可以选择接受或拒绝,点击查看详情。", to: "/app/expert-tasks", actionLabel: "查看详情" },
{ type: "todo", title: "《评标纪律承诺书》待签署", content: "进入评审前需先签署当前项目的评标纪律承诺书。", to: "/app/expert-tasks", actionLabel: "去签署" },
],
supplier: [
{ type: "ai", title: "聚丙烯项目报名已通过", content: "您在「年产5万吨聚丙烯项目」中的报名已通过,请查看招标文件;如确认投标,可缴纳投标保证金。", to: "/app/bidding", actionLabel: "查看招标文件" },
{ type: "urgent", title: "报名即将截止", content: "「污水处理提标改造工程」报名将于 7月15日截止,请尽快完成报名。", to: "/app/bidding", actionLabel: "去报名" },
],
committee: [
{ type: "risk", title: "异常决策待处理", content: "检测到 1 个项目触发异常上报(有效投标人不足 3 家),需领导小组决策。", to: "/app/exceptions", actionLabel: "去处理" },
],
auditor: [
{ type: "risk", title: "围串标风险提示", content: "AI 检测到 1 组投标文件相似度偏高,建议核查。", to: "/app/collusion", actionLabel: "去核查" },
],
};
const quickReplies = [
"如何发起招标委托?",
"帮我统计本年度项目预算总和",
"在线评标怎么打分?",
"公开招标和邀请招标的区别?",
];
interface Knowledge {
text: string;
actions?: MsgAction[];
}
const knowledge: { key: string; ans: Knowledge }[] = [
{
key: "委托",
ans: {
text:
"发起招标委托很简单:①点击工作台或项目管理页的「发起委托」按钮;②在「填写需求」步骤用自然语言描述需求,点击「AI 智能解析」即可自动生成《招标委托书》;③确认预填单后提交审批即可。",
actions: [{ label: "现在去发起委托", to: "/app/delegation" }],
},
},
{
key: "统计",
ans: {
text: "据统计,本年度一共有 13 个项目,预算总和为 12,348 万元。",
actions: [{ label: "查看项目列表", to: "/app/projects" }],
},
},
{
key: "预算",
ans: {
text: "据统计,本年度一共有 13 个项目,预算总和为 12,348 万元。",
actions: [{ label: "查看项目列表", to: "/app/projects" }],
},
},
{
key: "区别",
ans: {
text:
"公开招标面向所有符合条件的供应商发布寻源公告,参与方需报名审核;邀请招标则由您从长名单中筛选短名单进行定向邀请。招标方式在需求委托阶段即已确定,两者均支持全流程线上闭环。",
},
},
{
key: "评标",
ans: {
text:
"进入「在线评标室」后:左栏选择匿名投标人,中栏查看脱敏标书,右栏按技术分/商务分/价格分录入分数并填写评分说明,系统会同步展示 AI 雷同检测与价格合理性分析,最后点击「提交评分」。",
actions: [{ label: "进入在线评标室", to: "/app/projects/P2026001/evaluation" }],
},
},
{
key: "入库",
ans: {
text:
"供应商在「供应商管理」中提交入库申请,AI 会自动核验工商与风险信息;审核通过后系统将自动入库,无需单独操作。",
actions: [{ label: "前往供应商管理", to: "/app/suppliers" }],
},
},
{
key: "上传",
ans: {
text:
"进入「投标中心」→ 选择项目 → 在「上传投标文件」区域上传文件,系统会自动加密并上链存证。开标前任何人都无法查看,投标截止前还可撤回重传。",
actions: [{ label: "进入投标中心", to: "/app/bidding" }],
},
},
];
const dataKeywords = ["报价", "投标价", "多少钱", "金额", "中标", "得分", "分数", "名单", "谁"];
type PageType = "app" | "portal" | "login";
function getPointer(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) {
if ("touches" in e && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
if ("changedTouches" in e && e.changedTouches.length > 0) {
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
}
if ("clientX" in e) {
return { x: e.clientX, y: e.clientY };
}
return { x: 0, y: 0 };
}
const ENTRY_PROACTIVE: Proactive[] = [
{
type: "todo",
title: "欢迎使用智慧招标平台",
content: "您可以直接从首页查看交易信息、政策法规与操作指南。",
to: "/portal/trading-info",
actionLabel: "查看交易信息",
},
{
type: "ai",
title: "新用户建议先注册",
content: "供应商用户可先完成注册与实名认证,后续即可参与报名与投标。",
to: "/register",
actionLabel: "立即注册",
},
];
export function XiaobinAssistant({ pageType = "app" }: { pageType?: PageType }) {
const { sensitive, role, roleId } = useRole();
const navigate = useNavigate();
// 工作台默认展开;首页/登录页默认收起,避免遮挡主内容
const [open, setOpen] = useState(pageType === "app");
const [showChat, setShowChat] = useState(false);
const [input, setInput] = useState("");
const [proactive, setProactive] = useState<Proactive[]>(
pageType === "app" ? PROACTIVE[roleId] ?? [] : ENTRY_PROACTIVE,
);
const [msgs, setMsgs] = useState<Msg[]>([
{
role: "bot",
text: "您好,我是智能助手「小滨」👋 我可以为您解答系统操作与业务咨询。注意:我不参与任何业务决策哦。请问有什么可以帮您?",
},
]);
const endRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ dragging: false, dx: 0, dy: 0, sx: 0, sy: 0, moved: false });
const [pos, setPos] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const EDGE_GAP = 20;
const panelSize = open ? { w: 360, h: 520 } : { w: 68, h: 68 };
function clamp(x: number, y: number) {
const pad = EDGE_GAP;
const maxX = Math.max(pad, window.innerWidth - panelSize.w - pad);
const maxY = Math.max(pad, window.innerHeight - panelSize.h - pad);
return {
x: Math.min(Math.max(x, pad), maxX),
y: Math.min(Math.max(y, pad), maxY),
};
}
function startDrag(e: React.MouseEvent | React.TouchEvent) {
const p = getPointer(e);
dragRef.current.dragging = true;
dragRef.current.sx = p.x;
dragRef.current.sy = p.y;
dragRef.current.moved = false;
setDragging(true);
dragRef.current.dx = p.x - pos.x;
dragRef.current.dy = p.y - pos.y;
}
function snapToEdge(x: number, y: number) {
const pad = EDGE_GAP;
const maxX = Math.max(pad, window.innerWidth - panelSize.w - pad);
const maxY = Math.max(pad, window.innerHeight - panelSize.h - pad);
const targetX = x <= maxX / 2 ? pad : maxX;
return {
x: targetX,
y: Math.min(Math.max(y, pad), maxY),
};
}
// 切换角色时刷新主动提醒
useEffect(() => {
if (pageType === "app") setProactive(PROACTIVE[roleId] ?? []);
}, [roleId, pageType]);
useEffect(() => {
if (showChat) endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, showChat]);
// 初始位置(右下角)
useEffect(() => {
const w = pageType === "app" ? 360 : 68;
const h = pageType === "app" ? 520 : 68;
const p = clamp(window.innerWidth - w - 32, window.innerHeight - h - 32);
setPos(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageType]);
useEffect(() => {
function onMove(e: MouseEvent | TouchEvent) {
if (!dragRef.current.dragging) return;
const p = getPointer(e);
if (!dragRef.current.moved && (Math.abs(p.x - dragRef.current.sx) > 4 || Math.abs(p.y - dragRef.current.sy) > 4)) {
dragRef.current.moved = true;
}
const next = clamp(p.x - dragRef.current.dx, p.y - dragRef.current.dy);
setPos(next);
}
function onUp() {
if (!dragRef.current.dragging) return;
dragRef.current.dragging = false;
setDragging(false);
setPos((prev) => snapToEdge(prev.x, prev.y));
}
function onResize() {
setPos((prev) => clamp(prev.x, prev.y));
}
window.addEventListener("mousemove", onMove);
window.addEventListener("touchmove", onMove, { passive: false });
window.addEventListener("mouseup", onUp);
window.addEventListener("touchend", onUp);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("touchmove", onMove);
window.removeEventListener("mouseup", onUp);
window.removeEventListener("touchend", onUp);
window.removeEventListener("resize", onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const gradient = `linear-gradient(135deg, ${role.bannerFrom}, ${role.bannerTo})`;
const unread = proactive.length;
function answer(q: string): Knowledge {
if (sensitive && dataKeywords.some((k) => q.includes(k))) {
return {
text: "当前为流程敏感期(如开标前),数据类问题暂无法回答。请等待系统通知。我可以继续为您讲解系统操作哦~",
};
}
const hit = knowledge.find((k) => q.includes(k.key));
return (
hit?.ans ?? {
text:
"这是一个很好的问题。作为操作助手,我建议您查阅对应模块的帮助说明,或描述更具体的操作场景,我会尽力为您讲解系统使用方法。(温馨提示:业务决策需由有权限的人员完成)",
}
);
}
function send(text: string) {
const q = text.trim();
if (!q) return;
setShowChat(true);
setMsgs((m) => [...m, { role: "user", text: q }]);
setInput("");
setTimeout(() => {
const a = answer(q);
setMsgs((m) => [...m, { role: "bot", text: a.text, actions: a.actions }]);
}, 500);
}
function handleAction(p: Proactive) {
if (p.to) navigate(p.to);
}
return (
<div
className={cn(
"fixed z-50",
open ? "border bg-card shadow-2xl" : "border-0 bg-transparent shadow-none",
open ? "overflow-hidden" : "overflow-visible",
dragging ? "" : "transition-[left,top,width,height,border-radius] duration-260 ease-out",
)}
style={{
left: pos.x,
top: pos.y,
width: open ? 360 : 68,
height: open ? 520 : 68,
borderRadius: open ? 16 : "9999px",
}}
onClick={() => {
if (!open && !dragRef.current.moved) setOpen(true);
}}
>
{!open && (
<div
onMouseDown={startDrag}
onTouchStart={startDrag}
className="relative flex size-full cursor-grab items-center justify-center rounded-full text-white shadow-xl ring-1 ring-white/35 active:cursor-grabbing"
style={{ backgroundImage: gradient }}
aria-label="打开小滨 AI 助手"
>
<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="flex size-8 items-center justify-center rounded-full bg-white/22 ring-1 ring-white/35">
<Bot className="size-4.5" />
</span>
<span className="mt-1 text-[10px] font-medium text-white/95"></span>
</span>
{unread > 0 && (
<span className="absolute right-0 top-0 z-10 flex size-5 translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full border-2 border-white bg-[#f44336] text-[10px] font-bold text-white shadow-sm">
{unread}
</span>
)}
</div>
)}
<div
className={cn(
"flex h-full w-full flex-col",
open ? "opacity-100" : "pointer-events-none opacity-0",
dragging ? "" : "transition-opacity duration-180",
)}
>
{/* 头部 */}
<div
className="flex cursor-move items-center justify-between px-4 py-3 text-white"
style={{ backgroundImage: gradient }}
onMouseDown={startDrag}
onTouchStart={startDrag}
>
<div className="flex items-center gap-2.5">
<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"> 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" title="收起">
<Minus className="size-4" />
</button>
</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" />
</div>
)}
<div className="flex-1 space-y-3 overflow-y-auto bg-muted/30 p-4">
{!showChat ? (
<>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
📢 {proactive.length}
</span>
{proactive.length > 0 && (
<button
onClick={() => setShowChat(true)}
className="text-xs text-primary hover:underline"
>
</button>
)}
</div>
{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 rounded-md bg-white/70 px-2 py-1 text-xs font-bold", 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
className={cn(
"max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",
m.role === "user"
? "rounded-br-sm bg-primary text-primary-foreground"
: "rounded-bl-sm border bg-card text-card-foreground",
)}
>
<div>{m.text}</div>
{m.actions && m.actions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{m.actions.map((a) => (
<button
key={a.to}
onClick={() => navigate(a.to)}
className="inline-flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
>
<Sparkles className="size-3.5" /> {a.label}
</button>
))}
</div>
)}
</div>
</div>
))}
<div ref={endRef} />
</>
)}
</div>
<div className="flex items-center gap-2 border-t bg-card p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send(input)}
placeholder="输入您的问题…"
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 rounded-full" style={{ backgroundImage: gradient }}>
<Send className="size-4" />
</Button>
</div>
</div>
</div>
);
}