Files
STP/front-end/src/app/components/XiaobinAssistant.tsx
T
vame 7f0f79420c feat(front-end): 初始化STP前端工程并提交页面框架
引入前端项目基础结构、页面骨架和UI组件,建立可运行的开发与构建配置,方便后续按模块迭代。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 14:25:22 +08:00

159 lines
7.2 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 { Sparkles, X, Send, Bot, Lock } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "./ui/utils";
import { useRole } from "../role-context";
interface Msg {
role: "bot" | "user";
text: string;
}
const quickReplies = [
"如何发起招标委托?",
"邀请招标和公开招标有什么区别?",
"在线评标怎么打分?",
"供应商如何入库?",
];
const knowledge: Record<string, string> = {
:
"发起招标委托很简单:① 点击工作台或项目管理页的「发起委托」按钮;② 在「填写需求」步骤用自然语言描述需求,点击「AI 智能解析」即可自动生成《招标委托表》;③ 确认预填单后提交审批即可。需要我带您过去吗?",
:
"公开招标面向所有符合条件的供应商发布寻源公告,参与方需报名审核;邀请招标则由您从长名单中筛选短名单进行定向邀请。两者在本平台均支持全流程线上闭环。",
:
"进入「在线评标室」后:左栏选择匿名投标人,中栏查看脱敏标书,右栏按技术分/商务分/价格分录入分数并填写评分说明,系统会同步展示 AI 雷同检测与价格合理性分析,最后点击「提交评分」。",
:
"在「供应商管理」页点击「发起入库流程」,或在短名单管理中对供应商点击「发起入库」,系统将自动同步工商风险信息并进入审批。",
:
"您好,当系统中存在同类型未完成立项的委托时,系统会自动推送合并建议。您可以在立项阶段的「合并招标建议」卡片中查看并确认合并。需要我为您演示具体步骤吗?",
:
"进入「投标中心」→ 选择项目 → 在「上传投标文件」区域上传文件,系统会自动加密并上链存证。开标前任何人都无法查看,只有您本人可查看和修改,投标截止前还可撤回重传。",
};
const dataKeywords = ["报价", "投标价", "多少钱", "金额", "中标", "得分", "分数", "名单", "谁"];
export function XiaobinAssistant() {
const { sensitive } = useRole();
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [msgs, setMsgs] = useState<Msg[]>([
{
role: "bot",
text: "您好,我是智能助手「小滨」👋 我可以为您解答系统操作与业务咨询。注意:我不参与任何业务决策哦。请问有什么可以帮您?",
},
]);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, open]);
function answer(q: string) {
// 流程敏感期:拒答数据类问题
if (sensitive && dataKeywords.some((k) => q.includes(k))) {
return "当前为教学模式(流程敏感期),数据类问题暂无法回答。暂未到查看时间,请等待系统通知。我可以继续为您讲解系统操作哦~";
}
const key = Object.keys(knowledge).find((k) => q.includes(k));
return key
? knowledge[key]
: "这是一个很好的问题。作为操作助手,我建议您查阅对应模块的帮助说明,或描述更具体的操作场景,我会尽力为您讲解系统使用方法。(温馨提示:业务决策需由有权限的人员完成)";
}
function send(text: string) {
const q = text.trim();
if (!q) return;
setMsgs((m) => [...m, { role: "user", text: q }]);
setInput("");
setTimeout(() => {
setMsgs((m) => [...m, { role: "bot", text: answer(q) }]);
}, 500);
}
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"
>
<Sparkles className="size-5" />
<span className="text-sm font-medium"></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="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="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>
</div>
</div>
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15">
<X 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">
{msgs.map((m, i) => (
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[80%] 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",
)}
>
{m.text}
</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">
<input
value={input}
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"
/>
<Button size="icon" onClick={() => send(input)} className="size-9 shrink-0">
<Send className="size-4" />
</Button>
</div>
</div>
)}
</>
);
}