feat(front-end): 依据内部评审意见对前端 UI 进行二次升级优化

- 主题色统一改回科技蓝(呼应滨化 LOGO)
- 合并"采购需求人员"、移除审批人角色(审批在 OA)
- 发起委托归属采购需求人员,招标专员不含该功能
- 删除合并招标/定标审批/合同生成/合同管理/供应商入库/审批中心/流程配置页面
- 小滨助手默认展开可收起、各角色主动提醒、问答直接给出按钮/链接
- 待办中心去掉"事找人"前缀
- 新增后台智能体任务监控组件(招标专员/管理员)
- 发起委托增加导入采购计划/直接填写/选择模板(含自动)
- 项目列表:排序/高级搜索/委托人列/提交审批/列显隐
- 项目详情:修正流程步骤、委托人信息、方案与文件合一、提交审批
- 长短名单去分支与推荐部门列、寻源关联项目并支持三来源
- 开标去自动开标、专家管理去抽取与回避、供应商管理去新增与入库
This commit is contained in:
vame
2026-07-10 23:35:54 +08:00
parent 38a804ce41
commit d81ac936e7
23 changed files with 844 additions and 1720 deletions
-17
View File
@@ -12,15 +12,11 @@ import { ProjectDetail } from "./pages/ProjectDetail";
import { SupplierShortlist } from "./pages/SupplierShortlist";
import { BidOpeningHall } from "./pages/BidOpeningHall";
import { EvaluationRoom } from "./pages/EvaluationRoom";
import { AwardApproval } from "./pages/AwardApproval";
import { ContractGeneration } from "./pages/ContractGeneration";
import { SupplierManagement } from "./pages/SupplierManagement";
import { ExpertManagement } from "./pages/ExpertManagement";
import { ReportCenter } from "./pages/ReportCenter";
import { Placeholder } from "./pages/Placeholder";
import { ApprovalCenter } from "./pages/ApprovalCenter";
import { BiddingCenter } from "./pages/BiddingCenter";
import { MergeBidding } from "./pages/MergeBidding";
import { SmartSourcing } from "./pages/SmartSourcing";
import { DepositManagement } from "./pages/DepositManagement";
import { ExceptionDecision } from "./pages/ExceptionDecision";
@@ -28,10 +24,7 @@ import { ExpertTasks } from "./pages/ExpertTasks";
import { AuditLog } from "./pages/AuditLog";
import { CollusionAnalysis } from "./pages/CollusionAnalysis";
import { EvidenceVerification } from "./pages/EvidenceVerification";
import { ContractManagement } from "./pages/ContractManagement";
import { SupplierInbound } from "./pages/SupplierInbound";
import { UserManagement } from "./pages/admin/UserManagement";
import { ProcessConfig } from "./pages/admin/ProcessConfig";
import { KeyManagement } from "./pages/admin/KeyManagement";
export default function App() {
@@ -51,20 +44,11 @@ export default function App() {
<Route path="projects/:id/sourcing" element={<SmartSourcing />} />
<Route path="projects/:id/opening" element={<BidOpeningHall />} />
<Route path="projects/:id/evaluation" element={<EvaluationRoom />} />
<Route path="projects/:id/award" element={<AwardApproval />} />
<Route path="projects/:id/contract" element={<ContractGeneration />} />
{/* 招标专员 */}
<Route path="merge" element={<MergeBidding />} />
<Route path="deposits" element={<DepositManagement />} />
{/* 资源管理 */}
<Route path="suppliers" element={<SupplierManagement />} />
<Route path="experts" element={<ExpertManagement />} />
<Route path="reports" element={<ReportCenter />} />
{/* 采购专员 */}
<Route path="contracts" element={<ContractManagement />} />
<Route path="inbound" element={<SupplierInbound />} />
{/* 审批人 */}
<Route path="approvals" element={<ApprovalCenter />} />
{/* 领导小组 */}
<Route path="exceptions" element={<ExceptionDecision />} />
{/* 评标专家 */}
@@ -77,7 +61,6 @@ export default function App() {
<Route path="evidence" element={<EvidenceVerification />} />
{/* 管理员 */}
<Route path="admin/users" element={<UserManagement />} />
<Route path="admin/process" element={<ProcessConfig />} />
<Route path="admin/keys" element={<KeyManagement />} />
<Route path="settings" element={<Placeholder title="系统设置" />} />
</Route>
@@ -0,0 +1,112 @@
import { Bot, XCircle } from "lucide-react";
import { Card } from "./ui/card";
import { AiBadge } from "./shared/common";
import { toast } from "sonner";
type AgentStatus = "queued" | "running" | "done" | "failed";
interface AgentTask {
icon: string;
name: string;
project: string;
status: AgentStatus;
progress: number;
result: string;
remain: string;
}
// 示例数据(评审说明书第 6 节)
const TASKS: AgentTask[] = [
{ icon: "🔍", name: "AI 智慧寻源", project: "年产5万吨聚丙烯项目", status: "running", progress: 70, result: "已匹配:8 家供应商", remain: "预计剩余 2 分钟" },
{ icon: "🔬", name: "AI 雷同检测", project: "DCS 控制系统升级采购", status: "running", progress: 30, result: "已扫描:3 / 10 份", remain: "预计剩余 5 分钟" },
{ icon: "📊", name: "AI 价格分析", project: "环氧丙烷装置大修工程", status: "done", progress: 100, result: "发现 1 份报价偏离 >20%", remain: "09:35 完成" },
{ icon: "👨‍🏫", name: "AI 专家推荐", project: "污水处理提标改造工程", status: "queued", progress: 0, result: "排队中…", remain: "—" },
{ icon: "📄", name: "AI 评标报告生成", project: "实验室耗材集采", status: "failed", progress: 45, result: "模板解析异常,请重试", remain: "已终止" },
];
const STATUS: Record<AgentStatus, { bar: string; barBg: string; label: string; chip: string; dot: string }> = {
queued: { bar: "bg-[#9e9e9e]", barBg: "bg-[#eeeeee]", label: "⏸ 待执行", chip: "bg-slate-100 text-slate-500", dot: "#9e9e9e" },
running: { bar: "bg-gradient-to-r from-[#fb8c00] to-[#ffb74d]", barBg: "bg-[#fff3e0]", label: "⏳ 执行中", chip: "bg-amber-50 text-amber-600", dot: "#ff9800" },
done: { bar: "bg-[#4caf50]", barBg: "bg-[#e8f5e9]", label: "✅ 已完成", chip: "bg-emerald-50 text-emerald-600", dot: "#4caf50" },
failed: { bar: "bg-[#f44336]", barBg: "bg-[#ffebee]", label: "❌ 执行失败", chip: "bg-red-50 text-red-600", dot: "#f44336" },
};
export function AgentTaskMonitor() {
const running = TASKS.filter((t) => t.status === "running").length;
return (
<Card className="gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<Bot className="size-[18px] text-primary" />
<h3 className="text-foreground"></h3>
<AiBadge label="AI Agent" />
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{running} </span>
</div>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-500" /> 3
</span>
</div>
<div className="divide-y">
{TASKS.map((t, i) => {
const st = STATUS[t.status];
return (
<div key={i} className="flex items-center gap-4 px-6 py-3.5">
{/* 状态色条 */}
<span className="h-11 w-1.5 shrink-0 rounded-full" style={{ backgroundColor: st.dot }} />
{/* 图标 */}
<div
className="flex size-9 shrink-0 items-center justify-center rounded-full text-base"
style={{ backgroundColor: `${st.dot}1a` }}
>
{t.icon}
</div>
{/* 任务信息 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">{t.name}</span>
<span className={`rounded px-1.5 py-0.5 text-xs ${st.chip}`}>{st.label}</span>
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{t.project} · {t.result}
</div>
</div>
{/* 进度条 */}
<div className="hidden w-40 shrink-0 md:block">
<div className={`h-2 w-full overflow-hidden rounded-full ${st.barBg}`} title={`${t.progress}%`}>
<div className={`h-full rounded-full transition-all ${st.bar}`} style={{ width: `${t.progress}%` }} />
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-muted-foreground">
<span>{t.progress}%</span>
<span>{t.remain}</span>
</div>
</div>
{/* 操作 */}
<div className="w-20 shrink-0 text-right">
{t.status === "running" && (
<button
onClick={() => toast("已发送取消请求,正在终止任务…")}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive"
>
<XCircle className="size-3.5" />
</button>
)}
{t.status === "done" && (
<button onClick={() => toast.success("正在打开报告…")} className="text-xs font-medium text-primary hover:underline">
</button>
)}
{t.status === "failed" && (
<button onClick={() => toast("正在重试任务…")} className="text-xs font-medium text-primary hover:underline">
</button>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}
+138 -54
View File
@@ -1,14 +1,19 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router";
import { X, Send, Minus, Lock, ArrowRight } from "lucide-react";
import { X, Send, Minus, Lock, ArrowRight, Sparkles } 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";
@@ -23,8 +28,8 @@ interface Proactive {
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: "📋" },
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: "💡" },
};
@@ -32,61 +37,112 @@ const TYPE_STYLE: Record<ProactiveType, { bg: string; border: string; title: str
// 各角色的主动提醒(最多 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: "去审核" },
{ 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: "去受理" },
],
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: "查看" },
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: "请查看详情,接受邀请或申请回避(剩余不足 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: "查看" },
{ type: "urgent", title: "保温材料招标项目评标邀请", content: "您有一个「保温材料招标项目」的评标邀请,可以选择接受或拒绝,点击查看详情。", to: "/app/expert-tasks", actionLabel: "查看详情" },
{ type: "todo", title: "《评标纪律承诺书》待签署", content: "进入评审前需先签署当前项目的评标纪律承诺书", to: "/app/expert-tasks", actionLabel: "去签署" },
],
approver: [
{ type: "todo", title: "待审批事项 2 项", content: "有 2 个定标 / 立项审批等待您处理", to: "/app/approvals", 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 个项目触发异常上报,需领导小组决策", to: "/app/exceptions", actionLabel: "去处理" },
{ type: "risk", title: "异常决策待处理", content: "检测到 1 个项目触发异常上报(有效投标人不足 3 家),需领导小组决策", to: "/app/exceptions", actionLabel: "去处理" },
],
auditor: [
{ type: "risk", title: "围串标风险提示", content: "AI 检测到 1 组投标文件相似度偏高,建议核查", to: "/app/collusion", actionLabel: "去核查" },
{ type: "risk", title: "围串标风险提示", content: "AI 检测到 1 组投标文件相似度偏高,建议核查", to: "/app/collusion", actionLabel: "去核查" },
],
};
const quickReplies = ["如何发起招标委托?", "公开招标和邀请招标的区别?", "在线评标怎么打分?", "供应商如何入库?"];
const quickReplies = [
"如何发起招标委托?",
"帮我统计本年度项目预算总和",
"在线评标怎么打分?",
"公开招标和邀请招标的区别?",
];
const knowledge: Record<string, string> = {
:
"发起招标委托很简单:① 点击工作台或项目管理页的「发起委托」按钮;② 在「填写需求」步骤用自然语言描述需求,点击「AI 智能解析」即可自动生成《招标委托表》;③ 确认预填单后提交审批即可。需要我带您过去吗?",
:
"公开招标面向所有符合条件的供应商发布寻源公告,参与方需报名审核;邀请招标则由您从长名单中筛选短名单进行定向邀请。两者在本平台均支持全流程线上闭环。",
:
"进入「在线评标室」后:左栏选择匿名投标人,中栏查看脱敏标书,右栏按技术分/商务分/价格分录入分数并填写评分说明,系统会同步展示 AI 雷同检测与价格合理性分析,最后点击「提交评分」。",
:
"在「供应商管理」页点击「发起入库流程」,或在短名单管理中对供应商点击「发起入库」,系统将自动同步工商风险信息并进入审批。",
:
"当系统中存在同类型未完成立项的委托时,系统会自动推送合并建议。您可以在立项阶段的「合并招标建议」卡片中查看并确认合并。",
:
"进入「投标中心」→ 选择项目 → 在「上传投标文件」区域上传文件,系统会自动加密并上链存证。开标前任何人都无法查看,投标截止前还可撤回重传。",
};
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 = ["报价", "投标价", "多少钱", "金额", "中标", "得分", "分数", "名单", "谁"];
export function XiaobinAssistant() {
const { sensitive, role, roleId } = useRole();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
// 默认展开:用户进入工作台即可看到小滨的主动提醒,可随时收起
const [open, setOpen] = useState(true);
const [showChat, setShowChat] = useState(false);
const [input, setInput] = useState("");
const [proactive, setProactive] = useState<Proactive[]>(PROACTIVE[roleId] ?? []);
const [msgs, setMsgs] = useState<Msg[]>([
{
role: "bot",
text: "您好,我是智能助手「小滨」👋 我可以为您解答系统操作与业务咨询。注意:我不参与任何业务决策哦。请问有什么可以帮您?",
text: "您好我是智能助手「小滨」👋 我可以为您解答系统操作与业务咨询。注意我不参与任何业务决策哦。请问有什么可以帮您",
},
]);
const endRef = useRef<HTMLDivElement>(null);
@@ -103,14 +159,19 @@ export function XiaobinAssistant() {
const gradient = `linear-gradient(135deg, ${role.bannerFrom}, ${role.bannerTo})`;
const unread = proactive.length;
function answer(q: string) {
function answer(q: string): Knowledge {
if (sensitive && dataKeywords.some((k) => q.includes(k))) {
return "当前为流程敏感期(如开标前),数据类问题暂无法回答。请等待系统通知。我可以继续为您讲解系统操作哦~";
return {
text: "当前为流程敏感期(如开标前),数据类问题暂无法回答。请等待系统通知。我可以继续为您讲解系统操作哦~",
};
}
const key = Object.keys(knowledge).find((k) => q.includes(k));
return key
? knowledge[key]
: "这是一个很好的问题。作为操作助手,我建议您查阅对应模块的帮助说明,或描述更具体的操作场景,我会尽力为您讲解系统使用方法。(温馨提示:业务决策需由有权限的人员完成)";
const hit = knowledge.find((k) => q.includes(k.key));
return (
hit?.ans ?? {
text:
"这是一个很好的问题。作为操作助手,我建议您查阅对应模块的帮助说明,或描述更具体的操作场景,我会尽力为您讲解系统使用方法。(温馨提示:业务决策需由有权限的人员完成)",
}
);
}
function send(text: string) {
@@ -119,17 +180,19 @@ export function XiaobinAssistant() {
setShowChat(true);
setMsgs((m) => [...m, { role: "user", text: q }]);
setInput("");
setTimeout(() => setMsgs((m) => [...m, { role: "bot", text: answer(q) }]), 500);
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);
setOpen(false);
}
return (
<>
{/* 悬浮球 + 呼吸光环 */}
{/* 悬浮球 + 呼吸光环(收起状态) */}
{!open && (
<button
onClick={() => setOpen(true)}
@@ -163,14 +226,9 @@ export function XiaobinAssistant() {
</div>
</div>
</div>
<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>
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15" title="收起">
<Minus className="size-4" />
</button>
</div>
{sensitive && (
@@ -182,6 +240,19 @@ export function XiaobinAssistant() {
<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">
🎉
@@ -206,7 +277,7 @@ export function XiaobinAssistant() {
{p.to && (
<button
onClick={() => handleAction(p)}
className={cn("mt-2 inline-flex items-center gap-0.5 text-xs font-bold underline", st.title)}
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>
@@ -238,13 +309,26 @@ export function XiaobinAssistant() {
<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",
"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",
)}
>
{m.text}
<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>
))}
@@ -24,6 +24,8 @@ import {
Eye,
CheckCircle2,
TrendingUp,
FilePlus2,
Bot,
} from "lucide-react";
export const ICONS: Record<string, any> = {
@@ -52,4 +54,6 @@ export const ICONS: Record<string, any> = {
Eye,
CheckCircle2,
TrendingUp,
FilePlus2,
Bot,
};
+15 -49
View File
@@ -22,7 +22,9 @@ export interface WbConfig {
stats: WbStat[];
groups: WbGroup[];
/** 角色专属附加组件标识 */
extra?: "specialist" | "supplier" | "admin" | "auditor" | "committee" | "expert" | "approver";
extra?: "specialist" | "supplier" | "admin" | "auditor" | "committee" | "expert" | "requester";
/** 是否展示后台智能体任务监控模块 */
agentMonitor?: boolean;
primaryAction?: { label: string; link: string };
}
@@ -32,16 +34,19 @@ export const WORKBENCH: Record<RoleId, WbConfig> = {
stats: [
{ label: "进行中项目", value: "4" },
{ label: "已定标", value: "7" },
{ label: "保证金在途(万元)", value: "42" },
{ label: "被驳回", value: "1", alert: true },
],
extra: "requester",
primaryAction: { label: "✨ AI 发起新委托", link: "/app/delegation" },
groups: [
{
stage: "我的待办",
todos: [
{ title: "待确认的 AI 预填委托单", count: 2, project: "氯碱车间耐腐蚀阀门采购", link: "/app/delegation", ai: true, priority: "高" },
{ title: "待参与的技术标评审", count: 1, project: "DCS 控制系统升级采购", link: "/app/projects/P2026001/evaluation", priority: "中" },
{ title: "待补充需求资料", count: 1, project: "污水处理提标改造工程", link: "/app/projects/P2026004", priority: "低" },
{ title: "待确认中标结果", count: 1, project: "环氧丙烷装置大修工程", link: "/app/projects/P2026003", priority: "高" },
{ title: "待缴纳 / 退还保证金", count: 3, link: "/app/deposits", priority: "中" },
],
},
],
@@ -55,13 +60,13 @@ export const WORKBENCH: Record<RoleId, WbConfig> = {
{ label: "超时预警", value: "2", alert: true },
],
extra: "specialist",
primaryAction: { label: "+ 新建招标项目", link: "/app/delegation" },
agentMonitor: true,
primaryAction: { label: "+ 新建招标项目", link: "/app/projects" },
groups: [
{
stage: "需求阶段",
todos: [
{ title: "待确认委托单", count: 3, link: "/app/delegation", ai: true, priority: "高" },
{ title: "待合并招标建议", count: 1, link: "/app/merge", ai: true, priority: "中" },
{ title: "待受理委托单", count: 3, link: "/app/projects", ai: true, priority: "高" },
],
},
{
@@ -74,35 +79,15 @@ export const WORKBENCH: Record<RoleId, WbConfig> = {
{
stage: "评标阶段",
todos: [
{ title: "待确认专家抽取", count: 1, link: "/app/experts", priority: "高" },
{ title: "待组织专家抽取", count: 1, link: "/app/projects/P2026001", priority: "高" },
{ title: "待处理评标异常", count: 1, link: "/app/projects/P2026001/evaluation", priority: "高" },
],
},
{
stage: "定标阶段",
todos: [
{ title: "待生成评标报告", count: 1, link: "/app/projects/P2026003/award", ai: true, priority: "中" },
{ title: "待发起定标审批", count: 1, link: "/app/projects/P2026003/award", priority: "高" },
],
},
],
},
purchaser: {
greeting: "中标结果确认、合同生成与保证金管理一站式处理。",
stats: [
{ label: "待签约合同", value: "3" },
{ label: "本月应入库供应商", value: "5" },
{ label: "保证金应收(万元)", value: "126" },
],
primaryAction: { label: "✨ AI 生成合同", link: "/app/contracts" },
groups: [
{
stage: "我的待办",
todos: [
{ title: "待确认中标结果", count: 1, project: "环氧丙烷装置大修工程", link: "/app/projects/P2026003/award", priority: "高" },
{ title: "待生成合同", count: 2, link: "/app/contracts", ai: true, priority: "高" },
{ title: "待收取履约保证金", count: 3, link: "/app/deposits", priority: "中" },
{ title: "待发起供应商入库", count: 5, link: "/app/inbound", priority: "中" },
{ title: "待生成评标报告", count: 1, link: "/app/projects/P2026003", ai: true, priority: "中" },
{ title: "待提交定标结果", count: 1, link: "/app/projects/P2026003", priority: "高" },
],
},
],
@@ -145,26 +130,6 @@ export const WORKBENCH: Record<RoleId, WbConfig> = {
},
],
},
approver: {
greeting: "AI 已为每项审批提炼关键信息,助您高效决策。",
stats: [
{ label: "待审批总数", value: "12" },
{ label: "平均审批耗时", value: "2.4h" },
{ label: "本月已审批", value: "38" },
],
extra: "approver",
groups: [
{
stage: "按类型分组",
todos: [
{ title: "待审批委托", count: 4, link: "/app/approvals", priority: "中" },
{ title: "待审批立项", count: 3, link: "/app/approvals", priority: "中" },
{ title: "待审批公告", count: 2, link: "/app/approvals", priority: "高" },
{ title: "待审批定标", count: 3, link: "/app/approvals", priority: "高" },
],
},
],
},
supplier: {
greeting: "AI 已为您推荐高匹配度项目,请关注报名与投标截止时间。",
stats: [
@@ -193,13 +158,14 @@ export const WORKBENCH: Record<RoleId, WbConfig> = {
{ label: "待处理告警", value: "2", alert: true },
],
extra: "admin",
agentMonitor: true,
groups: [
{
stage: "待处理事项",
todos: [
{ title: "用户权限申请", count: 3, link: "/app/admin/users", priority: "中" },
{ title: "流程配置变更", count: 1, link: "/app/admin/process", priority: "中" },
{ title: "密钥漂移待执行", count: 1, link: "/app/admin/keys", priority: "高" },
{ title: "系统告警待处理", count: 2, link: "/app/audit-logs", priority: "高" },
],
},
],
-183
View File
@@ -1,183 +0,0 @@
import { useState } from "react";
import { Sparkles, ClipboardCheck, ArrowLeft, ThumbsUp, RotateCcw, Forward, FileText, Paperclip } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { PageHeader, ApprovalTracker } from "../components/shared/common";
import { approvals, type ApprovalItem } from "../data/mock";
export function ApprovalCenter() {
const [filter, setFilter] = useState("全部");
const [active, setActive] = useState<ApprovalItem | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const types = ["全部", "委托审批", "立项审批", "公告审批", "定标审批"];
const list = approvals.filter((a) => filter === "全部" || a.type === filter);
if (active) return <ApprovalDetail item={active} onBack={() => setActive(null)} />;
function toggle(id: string) {
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
}
return (
<div>
<PageHeader
title="待审批"
description="AI 已为每项审批提炼关键信息,支持批量审批同类型简单事项"
actions={
selected.length > 0 ? (
<Button onClick={() => { toast.success(`已批量通过 ${selected.length} 项审批`); setSelected([]); }}>
<ThumbsUp className="size-4" /> {selected.length}
</Button>
) : undefined
}
/>
<Tabs value={filter} onValueChange={setFilter} className="mb-4">
<TabsList>
{types.map((t) => (
<TabsTrigger key={t} value={t}>
{t}
{t !== "全部" && (
<span className="ml-1 rounded-full bg-muted px-1.5 text-xs">
{approvals.filter((a) => a.type === t).length}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="space-y-3">
{list.map((a) => (
<Card key={a.id} className="flex-row items-center gap-4 p-5">
<input
type="checkbox"
className="size-4 accent-[var(--primary)]"
checked={selected.includes(a.id)}
onChange={() => toggle(a.id)}
/>
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600">
<ClipboardCheck className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-emerald-50 px-2 py-0.5 text-xs text-emerald-600">{a.type}</span>
{a.urgent && <span className="rounded bg-red-50 px-2 py-0.5 text-xs text-red-600"></span>}
</div>
<div className="mt-1 font-medium text-foreground">{a.project}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{a.applicant} · {a.amount.toLocaleString()} · {a.time}
</div>
</div>
<Button onClick={() => setActive(a)}></Button>
</Card>
))}
</div>
</div>
);
}
function ApprovalDetail({ item, onBack }: { item: ApprovalItem; onBack: () => void }) {
return (
<div>
<div className="mb-5 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-5" />
</Button>
<div>
<h1 className="text-foreground">{item.type}</h1>
<p className="text-sm text-muted-foreground">{item.project}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2 space-y-6">
{/* AI 审批摘要 */}
<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>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{item.summary}</p>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ l: "申请金额", v: `${item.amount.toLocaleString()} 万元` },
{ l: "申请人", v: item.applicant },
{ l: "提交时间", v: item.time },
].map((x) => (
<div key={x.l} className="rounded-lg bg-white/70 p-3">
<div className="text-xs text-muted-foreground">{x.l}</div>
<div className="mt-0.5 font-medium text-foreground">{x.v}</div>
</div>
))}
</div>
</Card>
{/* 申请单据(只读) */}
<Card className="gap-0 p-6">
<div className="mb-4 flex items-center gap-2">
<FileText className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
</div>
<div className="space-y-3 text-sm">
{[
["项目名称", item.project],
["审批类型", item.type],
["预算/金额", `${item.amount.toLocaleString()} 万元`],
["申请部门", "招标采购中心"],
["事项说明", item.summary],
].map(([l, v]) => (
<div key={l} className="flex gap-4 border-b pb-3 last:border-0">
<span className="w-24 shrink-0 text-muted-foreground">{l}</span>
<span className="text-foreground">{v}</span>
</div>
))}
</div>
<div className="mt-4 flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm">
<Paperclip className="size-4 text-muted-foreground" />
<span className="flex-1 text-foreground">{item.project}-.pdf</span>
<Button size="sm" variant="outline" className="h-7">线</Button>
</div>
</Card>
{/* 审批操作 */}
<Card className="gap-0 p-6">
<h3 className="text-foreground"></h3>
<label className="mb-1.5 mt-3 block text-sm"></label>
<textarea
rows={3}
placeholder="请填写审批意见(驳回时必填)…"
className="w-full resize-none rounded-lg border bg-input-background p-3 text-sm outline-none focus:border-primary"
/>
<div className="mt-4 flex gap-3">
<Button className="flex-1 bg-emerald-600 hover:bg-emerald-700" onClick={() => { toast.success("已审批通过"); onBack(); }}>
<ThumbsUp className="size-4" />
</Button>
<Button variant="outline" className="flex-1 text-destructive" onClick={() => { toast.error("已驳回"); onBack(); }}>
<RotateCcw className="size-4" />
</Button>
<Button variant="outline" onClick={() => toast("已转签给下一审批人")}>
<Forward className="size-4" />
</Button>
</div>
</Card>
</div>
<Card className="gap-0 p-6">
<h4 className="mb-4 text-foreground"></h4>
<ApprovalTracker
nodes={[
{ role: "经办人提交", name: item.applicant, status: "completed", time: item.time },
{ role: "部门负责人", name: "已通过", status: "completed" },
{ role: "您(当前节点)", name: "审批中", status: "current" },
{ role: "审批完成", name: "流程归档", status: "pending" },
]}
/>
</Card>
</div>
</div>
);
}
-147
View File
@@ -1,147 +0,0 @@
import { useNavigate } from "react-router";
import { ArrowLeft, Sparkles, Trophy, ThumbsUp, RotateCcw, FileText } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { AiBadge, ApprovalTracker } from "../components/shared/common";
const ranking = [
{ rank: 1, name: "供应商 A(浙江中控技术)", tech: 56, biz: 18, price: 28, total: 102, reason: "技术方案最优,冗余架构与本地化服务承诺突出,综合得分第一。" },
{ rank: 2, name: "供应商 B(和利时科技)", tech: 52, biz: 17, price: 30, total: 99, reason: "报价最具竞争力,技术方案满足要求,略逊于第一名。" },
{ rank: 3, name: "供应商 C(西门子中国)", tech: 54, biz: 16, price: 24, total: 94, reason: "品牌与性能优秀,但报价偏高,价格分较低。" },
];
export function AwardApproval() {
const navigate = useNavigate();
return (
<div>
<div className="mb-1 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="size-5" />
</Button>
<div>
<h1 className="text-foreground"></h1>
<p className="text-sm text-muted-foreground"> · </p>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-6">
<div className="col-span-2 space-y-6">
{/* 评标报告 */}
<Card className="gap-0 p-6">
<div className="mb-4 flex items-center gap-2">
<FileText className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
<AiBadge label="AI 汇总" />
</div>
{/* AI 评标汇总表 */}
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-left text-muted-foreground">
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{ranking.map((r) => (
<tr key={r.rank} className={r.rank === 1 ? "bg-amber-50/50" : ""}>
<td className="px-4 py-3">
{r.rank === 1 ? (
<span className="inline-flex items-center gap-1 font-medium text-amber-600">
<Trophy className="size-4" /> 1
</span>
) : (
<span className="text-muted-foreground"> {r.rank} </span>
)}
</td>
<td className="px-4 py-3 font-medium text-foreground">{r.name}</td>
<td className="px-4 py-3 text-muted-foreground">{r.tech}</td>
<td className="px-4 py-3 text-muted-foreground">{r.biz}</td>
<td className="px-4 py-3 text-muted-foreground">{r.price}</td>
<td className="px-4 py-3 font-semibold text-foreground">{r.total}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 中标候选人排名及理由 */}
<div className="mt-5">
<h4 className="mb-3 text-foreground"></h4>
<div className="space-y-3">
{ranking.map((r) => (
<div key={r.rank} className="flex gap-3 rounded-lg border bg-card p-4">
<div
className={`flex size-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold ${
r.rank === 1 ? "bg-amber-100 text-amber-700" : "bg-muted text-muted-foreground"
}`}
>
{r.rank}
</div>
<div>
<div className="font-medium text-foreground">{r.name}</div>
<p className="mt-1 text-sm text-muted-foreground">
<Sparkles className="mr-1 inline size-3.5 text-violet-500" />
{r.reason}
</p>
</div>
</div>
))}
</div>
</div>
</Card>
{/* 审批决策 */}
<Card className="gap-0 p-6">
<h3 className="text-foreground"></h3>
<div className="mt-3 rounded-lg bg-primary/5 p-4 text-sm">
<span className="font-semibold text-primary"> A</span>
</div>
<label className="mb-1.5 mt-4 block text-sm"></label>
<textarea
rows={3}
placeholder="请填写您的审批意见…"
className="w-full resize-none rounded-lg border bg-input-background p-3 text-sm outline-none focus:border-primary"
/>
<div className="mt-4 flex gap-3">
<Button
className="flex-1"
onClick={() => {
toast.success("已同意推荐中标人,进入合同生成阶段");
setTimeout(() => navigate("/app/projects/P2026003/contract"), 800);
}}
>
<ThumbsUp className="size-4" />
</Button>
<Button
variant="outline"
className="flex-1 text-destructive"
onClick={() => toast.error("已驳回,项目将重新评标")}
>
<RotateCcw className="size-4" />
</Button>
</div>
</Card>
</div>
<Card className="gap-0 p-6">
<h4 className="mb-4 text-foreground"></h4>
<ApprovalTracker
nodes={[
{ role: "评标委员会", name: "完成评标并出具报告", status: "completed", time: "06-20 16:00" },
{ role: "招标专员复核", name: "王强 · 已复核", status: "completed", time: "06-21 09:30" },
{ role: "分管领导审批", name: "分管副总 · 审批中", status: "current" },
{ role: "定标完成", name: "发出中标通知书", status: "pending" },
]}
/>
</Card>
</div>
</div>
);
}
+5 -5
View File
@@ -25,7 +25,7 @@ interface LogItem {
}
const logSeq: LogItem[] = [
{ time: "10:00:00", title: "系统自动开标", desc: "开标时间到达,系统自动启动开标程序", icon: Play, color: "#2563eb" },
{ time: "10:00:00", title: "开标启动", desc: "招标专员在开标时间到达启动开标程序", icon: Play, color: "#1565c0" },
{ time: "10:00:03", title: "投标截止确认", desc: "确认共收到 3 家有效投标文件", icon: CheckCircle2, color: "#0ea5e9" },
{ time: "10:00:08", title: "文件自动解密", desc: "使用投标人 CA 证书自动解密投标文件", icon: Unlock, color: "#14b8a6" },
{ time: "10:00:15", title: "投标文件哈希上链", desc: "各投标文件哈希值已写入区块链存证,不可篡改", icon: Link2, color: "#8b5cf6" },
@@ -52,7 +52,7 @@ export function BidOpeningHall() {
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="size-5" />
</Button>
<PageHeader title="开标大厅" description="DCS 控制系统升级采购 · 自动化开标与链上存证" />
<PageHeader title="开标大厅" description="DCS 控制系统升级采购 · 在线开标与链上存证" />
</div>
{/* 顶部状态条 */}
@@ -79,7 +79,7 @@ export function BidOpeningHall() {
</div>
{!started && (
<Button className="bg-white text-primary hover:bg-white/90" onClick={() => setStarted(true)}>
<Play className="size-4" />
<Play className="size-4" />
</Button>
)}
</div>
@@ -92,7 +92,7 @@ export function BidOpeningHall() {
{!started ? (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<Clock className="size-10 text-muted-foreground/40" />
<p className="text-sm"></p>
<p className="text-sm"></p>
</div>
) : (
<div className="space-y-0">
@@ -149,7 +149,7 @@ export function BidOpeningHall() {
)}
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Link2 className="size-3.5 text-violet-500" />
<Link2 className="size-3.5 text-primary" />
<span className="font-mono">{revealed ? b.hash : "····················"}</span>
</div>
</div>
@@ -1,131 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { ArrowLeft, Sparkles, FileSignature, PenTool, Check, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { AiBadge, ApprovalTracker } from "../components/shared/common";
const diffs = [
{ clause: "付款方式", standard: "验收合格后 30 日内支付 100%", current: "预付 10%,验收后 80%,质保期满付 10%", type: "modified" },
{ clause: "质保期", standard: "12 个月", current: "36 个月", type: "modified" },
{ clause: "违约金比例", standard: "合同总额 5%", current: "合同总额 5%", type: "same" },
{ clause: "争议解决", standard: "提交滨州仲裁委员会仲裁", current: "提交滨州仲裁委员会仲裁", type: "same" },
{ clause: "履约保证金", standard: "(无)", current: "合同总额 10%,履约完成后退还", type: "added" },
];
export function ContractGeneration() {
const navigate = useNavigate();
const [signing, setSigning] = useState(false);
const [signed, setSigned] = useState(false);
function sign() {
setSigning(true);
setTimeout(() => {
setSigning(false);
setSigned(true);
toast.success("电子签章流程已发起,等待双方签署");
}, 1500);
}
return (
<div>
<div className="mb-1 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="size-5" />
</Button>
<div>
<h1 className="text-foreground"></h1>
<p className="text-sm text-muted-foreground"> · </p>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-6">
<Card className="col-span-2 gap-0 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileSignature className="size-5 text-primary" />
<h3 className="text-foreground">稿 · </h3>
<AiBadge label="AI 拟稿" />
</div>
<span className="text-sm text-muted-foreground"></span>
</div>
<div className="mb-4 flex gap-3 text-xs">
<span className="flex items-center gap-1"><span className="size-3 rounded bg-amber-100" /> </span>
<span className="flex items-center gap-1"><span className="size-3 rounded bg-emerald-100" /> </span>
<span className="flex items-center gap-1"><span className="size-3 rounded bg-muted" /> </span>
</div>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-left text-muted-foreground">
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{diffs.map((d) => (
<tr
key={d.clause}
className={
d.type === "modified"
? "bg-amber-50/40"
: d.type === "added"
? "bg-emerald-50/40"
: ""
}
>
<td className="px-4 py-3 font-medium text-foreground">{d.clause}</td>
<td className="px-4 py-3 text-muted-foreground">{d.standard}</td>
<td className="px-4 py-3 text-foreground">{d.current}</td>
<td className="px-4 py-3">
{d.type === "modified" && <span className="text-amber-600"></span>}
{d.type === "added" && <span className="text-emerald-600"></span>}
{d.type === "same" && <span className="text-muted-foreground"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50/60 p-3 text-sm text-muted-foreground">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-500" />
2 1
</div>
<div className="mt-5 flex justify-end gap-3">
<Button variant="outline"></Button>
{!signed ? (
<Button onClick={sign} disabled={signing}>
{signing ? <Sparkles className="size-4 animate-spin" /> : <PenTool className="size-4" />}
{signing ? "发起中…" : "发起电子签章"}
</Button>
) : (
<Button onClick={() => { toast.success("合同已归档"); navigate("/app/projects"); }}>
<Check className="size-4" />
</Button>
)}
</div>
</Card>
<Card className="gap-0 p-6">
<h4 className="mb-4 text-foreground"></h4>
<ApprovalTracker
nodes={[
{ role: "合同生成", name: "AI 自动拟稿完成", status: "completed", time: "06-22 10:00" },
{ role: "法务审核", name: "法务合规 · 已通过", status: signed ? "completed" : "current", time: signed ? "06-22 11:20" : undefined },
{ role: "采购方签章", name: "滨化集团 · 电子签章", status: signed ? "current" : "pending" },
{ role: "供应商签章", name: "浙江中控技术", status: "pending" },
{ role: "合同归档", name: "归档至合同管理库", status: "pending" },
]}
/>
</Card>
</div>
</div>
);
}
@@ -1,93 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { Sparkles, Search, FileSignature, PenTool } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { PageHeader } from "../components/shared/common";
import { contracts } from "../data/mock";
const statusColor: Record<string, string> = {
稿: "bg-slate-100 text-slate-600",
: "bg-amber-100 text-amber-700",
: "bg-blue-100 text-blue-700",
: "bg-emerald-100 text-emerald-700",
: "bg-teal-100 text-teal-700",
};
export function ContractManagement() {
const navigate = useNavigate();
const [q, setQ] = useState("");
const list = contracts.filter((c) => c.project.includes(q) || c.code.includes(q));
return (
<div>
<PageHeader
title="合同管理"
description="AI 自动生成合同初稿,差异项高亮标注,支持电子签章"
actions={
<Button onClick={() => navigate("/app/projects/P2026003/contract")}>
<Sparkles className="size-4" /> AI
</Button>
}
/>
<Card className="mb-4 flex-row items-center gap-3 p-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索合同编号 / 项目名称"
className="h-9 w-full rounded-lg border bg-input-background pl-9 pr-3 text-sm outline-none focus:border-primary"
/>
</div>
</Card>
<Card className="gap-0 overflow-hidden p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-5 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium">()</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{list.map((c) => (
<tr key={c.id} className="hover:bg-muted/20">
<td className="px-5 py-3 font-mono text-xs text-foreground">{c.code}</td>
<td className="px-4 py-3 font-medium text-foreground">{c.project}</td>
<td className="px-4 py-3 text-muted-foreground">{c.supplier}</td>
<td className="px-4 py-3 font-medium text-foreground">{c.amount.toLocaleString()}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs ${statusColor[c.status]}`}>{c.status}</span>
</td>
<td className="px-4 py-3">
{c.status === "草稿" && (
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => navigate("/app/projects/P2026003/contract")}>
<Sparkles className="size-3.5" />
</Button>
)}
{c.status === "待签署" && (
<Button size="sm" className="h-7 gap-1" onClick={() => toast.success("已发起电子签章")}>
<PenTool className="size-3.5" />
</Button>
)}
{(c.status === "已生效" || c.status === "已归档" || c.status === "审批中") && (
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => toast("打开合同详情")}>
<FileSignature className="size-3.5" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
);
}
+64 -4
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { ArrowLeft, Sparkles, Loader2, Check, ArrowRight, FileText } from "lucide-react";
import { ArrowLeft, Sparkles, Loader2, Check, ArrowRight, FileText, FileDown, LayoutTemplate } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
@@ -102,6 +102,7 @@ export function Delegation() {
);
const [parsing, setParsing] = useState(false);
const [parsed, setParsed] = useState(false);
const [template, setTemplate] = useState("自动");
const form = parsed ? filledForm : emptyForm;
@@ -131,8 +132,66 @@ export function Delegation() {
<Steps steps={STEPS} current={step} />
</Card>
{/* 步骤一:填写需求 */}
{/* 步骤一:填写需求 —— 多种发起方式 */}
{step === 0 && (
<>
<Card className="mb-6 gap-0 p-5">
<div className="mb-3 flex items-center gap-2">
<h3 className="text-foreground"></h3>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => {
setParsed(true);
toast.success("已导入采购计划,AI 已据此预填委托单");
}}
className="group flex items-start gap-3 rounded-xl border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary group-hover:bg-primary group-hover:text-white">
<FileDown className="size-5" />
</div>
<div>
<div className="font-medium text-foreground"></div>
<p className="mt-0.5 text-xs text-muted-foreground"></p>
</div>
</button>
<button
onClick={() => setStep(1)}
className="group flex items-start gap-3 rounded-xl border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary group-hover:bg-primary group-hover:text-white">
<FileText className="size-5" />
</div>
<div>
<div className="font-medium text-foreground"></div>
<p className="mt-0.5 text-xs text-muted-foreground"> AI </p>
</div>
</button>
<div className="flex items-start gap-3 rounded-xl border bg-card p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary">
<LayoutTemplate className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground"></div>
<select
value={template}
onChange={(e) => {
setTemplate(e.target.value);
toast.success(e.target.value === "自动" ? "已设为 AI 自动选择最合适的模板" : `已选择「${e.target.value}」模板`);
}}
className="mt-1 h-8 w-full rounded-md border bg-input-background px-2 text-xs outline-none focus:border-primary"
>
<option value="自动">🤖 AI </option>
<option value="软件采购"></option>
<option value="工程施工"></option>
<option value="货物采购"></option>
<option value="服务采购"></option>
</select>
</div>
</div>
</div>
</Card>
<div className="grid grid-cols-3 gap-6">
<Card className="col-span-2 gap-0 p-6">
<div className="mb-3 flex items-center justify-between">
@@ -148,7 +207,7 @@ export function Delegation() {
/>
<div className="mt-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
<Sparkles className="mr-1 inline size-3.5 text-violet-500" />
<Sparkles className="mr-1 inline size-3.5 text-primary" />
</p>
<Button onClick={handleParse} disabled={parsing}>
@@ -167,7 +226,7 @@ export function Delegation() {
<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" />
<Sparkles className="size-4 text-primary" />
<h4 className="text-foreground">AI </h4>
</div>
<ul className="mt-3 space-y-2.5 text-sm text-muted-foreground">
@@ -202,6 +261,7 @@ export function Delegation() {
</Card>
)}
</div>
</>
)}
{/* 步骤二:确认预填单 */}
+5 -122
View File
@@ -1,12 +1,5 @@
import { useState } from "react";
import { Search, Filter, Dices, Loader2, Check, ShieldAlert, UserCog } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../components/ui/dialog";
import { Search, Filter } from "lucide-react";
import {
Select,
SelectContent,
@@ -14,42 +7,25 @@ import {
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { PageHeader } from "../components/shared/common";
import { experts } from "../data/mock";
// 说明(评审意见):专家的随机抽取与回避校验属于"具体项目评标流程",
// 不在专家总体管理页体现;本页仅做专家库信息维护与查询。
export function ExpertManagement() {
const [q, setQ] = useState("");
const [cat, setCat] = useState("all");
const [open, setOpen] = useState(false);
const [drawing, setDrawing] = useState(false);
const [result, setResult] = useState<typeof experts | null>(null);
const list = experts.filter(
(e) => (cat === "all" || e.category === cat) && e.name.includes(q),
);
function draw() {
setDrawing(true);
setResult(null);
setTimeout(() => {
setDrawing(false);
// 抽取未需回避的专家
setResult(experts.filter((e) => !e.avoid).slice(0, 3));
}, 1800);
}
return (
<div>
<PageHeader
title="专家管理"
description="管理内外部评标专家,支持随机抽取与回避校验"
actions={
<Button onClick={() => { setOpen(true); setResult(null); }}>
<Dices className="size-4" />
</Button>
}
description="维护内外部评标专家库信息(随机抽取与回避校验在具体项目评标流程中进行)"
/>
<Card className="mb-4 flex-row items-center gap-3 p-4">
@@ -88,7 +64,6 @@ export function ExpertManagement() {
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
@@ -102,109 +77,17 @@ export function ExpertManagement() {
<td className="px-4 py-4">
<span
className={`rounded px-2 py-0.5 text-xs ${
e.type === "内部" ? "bg-blue-50 text-blue-600" : "bg-violet-50 text-violet-600"
e.type === "内部" ? "bg-blue-50 text-blue-600" : "bg-sky-50 text-sky-600"
}`}
>
{e.type}
</span>
</td>
<td className="px-4 py-4">
{e.avoid ? (
<span className="inline-flex items-center gap-1 text-red-600">
<ShieldAlert className="size-4" />
</span>
) : (
<span className="text-emerald-600"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
{/* 抽取弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Dices className="size-5 text-primary" />
</DialogTitle>
</DialogHeader>
{!result && !drawing && (
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<label className="mb-1.5 block"></label>
<Select defaultValue="自动化控制">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="自动化控制"></SelectItem>
<SelectItem value="化工工艺"></SelectItem>
<SelectItem value="环保工程"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1.5 block"></label>
<Select defaultValue="3">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="3">3 </SelectItem>
<SelectItem value="5">5 </SelectItem>
<SelectItem value="7">7 </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-start gap-2 rounded-lg bg-muted/40 p-3 text-xs text-muted-foreground">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-amber-500" />
</div>
</div>
)}
{drawing && (
<div className="flex flex-col items-center justify-center gap-3 py-10">
<Loader2 className="size-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground"></p>
</div>
)}
{result && (
<div className="space-y-3 py-2">
<div className="flex items-center gap-2 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
<Check className="size-4" /> 1
</div>
{result.map((e) => (
<div key={e.id} className="flex items-center gap-3 rounded-lg border bg-card p-3">
<div className="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
<UserCog className="size-4" />
</div>
<div className="flex-1">
<div className="font-medium text-foreground">{e.name}</div>
<div className="text-xs text-muted-foreground">
{e.title} · {e.category} · {e.region}
</div>
</div>
<span className="text-xs text-emerald-600"></span>
</div>
))}
</div>
)}
<DialogFooter>
{!result ? (
<Button onClick={draw} disabled={drawing}>
<Dices className="size-4" /> {drawing ? "抽取中…" : "开始抽取"}
</Button>
) : (
<Button onClick={() => setOpen(false)}></Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-123
View File
@@ -1,123 +0,0 @@
import { useState } from "react";
import { GitMerge, Sparkles, Check, TrendingDown, Clock, Users, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { PageHeader, AiBadge } from "../components/shared/common";
import { mergeCandidates } from "../data/mock";
export function MergeBidding() {
const [picked, setPicked] = useState<string[]>(mergeCandidates.map((m) => m.id));
const [merged, setMerged] = useState(false);
const chosen = mergeCandidates.filter((m) => picked.includes(m.id));
const totalBudget = chosen.reduce((s, m) => s + m.budget, 0);
function toggle(id: string) {
setPicked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
}
return (
<div>
<PageHeader title="合并招标" description="AI 检测同类型未完成立项的委托,给出合并建议与效益估算" />
{/* 提示横幅 */}
<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>
</p>
</div>
{merged ? (
<Card className="flex flex-col items-center justify-center gap-4 py-16">
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-100">
<Check className="size-8 text-emerald-600" />
</div>
<h2 className="text-foreground"></h2>
<p className="max-w-md text-center text-sm text-muted-foreground">
{chosen.length}
{chosen.map((c) => c.name).join("、")}
</p>
<Button onClick={() => toast.success("已跳转至新项目")}></Button>
</Card>
) : (
<div className="grid grid-cols-3 gap-6">
{/* 可合并项目 */}
<Card className="col-span-2 gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<GitMerge className="size-5 text-violet-500" />
<h3 className="text-foreground"></h3>
<AiBadge label="AI 推荐" />
</div>
<span className="text-sm text-muted-foreground"> {chosen.length} / {mergeCandidates.length}</span>
</div>
<div className="divide-y">
{mergeCandidates.map((m) => {
const on = picked.includes(m.id);
return (
<button
key={m.id}
onClick={() => toggle(m.id)}
className="flex w-full items-center gap-4 px-6 py-4 text-left hover:bg-muted/20"
>
<div
className={`flex size-5 shrink-0 items-center justify-center rounded border ${
on ? "border-primary bg-primary text-white" : "border-border"
}`}
>
{on && <Check className="size-3.5" />}
</div>
<div className="flex-1">
<div className="font-medium text-foreground">{m.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{m.dept} · {m.budget} · {m.category}
</div>
</div>
<span className="rounded-md bg-violet-50 px-2 py-1 text-xs text-violet-600">
{m.overlap}%
</span>
</button>
);
})}
</div>
</Card>
{/* 效益估算 */}
<Card className="gap-0 p-6">
<h4 className="mb-4 text-foreground"></h4>
<div className="space-y-4">
<Benefit icon={Users} color="#2563eb" label="预算汇总" value={`${totalBudget} 万元`} desc={`合并 ${chosen.length} 个委托`} />
<Benefit icon={Clock} color="#14b8a6" label="时间节省估算" value="~15 天" desc="减少重复寻源与评标" />
<Benefit icon={TrendingDown} color="#8b5cf6" label="供应商资源优化" value="↓ 30%" desc="集中议价提升规模效益" />
</div>
<div className="mt-6 space-y-2">
<Button className="w-full" disabled={chosen.length < 2} onClick={() => { setMerged(true); toast.success("委托已合并"); }}>
<GitMerge className="size-4" />
</Button>
<Button variant="outline" className="w-full" onClick={() => toast("已暂不合并")}>
<X className="size-4" />
</Button>
</div>
</Card>
</div>
)}
</div>
);
}
function Benefit({ icon: Icon, color, label, value, desc }: { icon: any; color: string; label: string; value: string; desc: string }) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-card p-3">
<div className="flex size-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${color}1a`, color }}>
<Icon className="size-5" />
</div>
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="font-semibold text-foreground">{value}</div>
<div className="text-xs text-muted-foreground">{desc}</div>
</div>
</div>
);
}
+41 -82
View File
@@ -7,8 +7,6 @@ import {
FileText,
Users,
Gavel,
Award,
FileSignature,
Building2,
Eye,
Send,
@@ -18,30 +16,21 @@ import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../components/ui/tabs";
import { StatusBadge, AiBadge, Steps } from "../components/shared/common";
import { StatusBadge, Steps } from "../components/shared/common";
import { projects } from "../data/mock";
const PHASES = ["需求委托", "立项审批", "招标文件", "投标开标", "在线评标", "定标审批", "合同归档"];
// 正确的招标业务流程(评审意见修正)
const PHASES = ["发起委托", "供应商寻源", "招标立项", "供应商投标", "开标评标", "定标", "归档"];
export function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const project = projects.find((p) => p.id === id) ?? projects[0];
const phaseIdx = Math.max(0, PHASES.indexOf(project.status === "已完成" ? "合同归档" : project.status));
const phaseIdx = project.status === "已完成" ? PHASES.length - 1 : 4;
const [genPlan, setGenPlan] = useState(false);
const [planDone, setPlanDone] = useState(false);
const [genFile, setGenFile] = useState(false);
const [fileDone, setFileDone] = useState(false);
function runPlan() {
setGenPlan(true);
setTimeout(() => {
setGenPlan(false);
setPlanDone(true);
toast.success("AI 已生成招标方案,请查看方案摘要");
}, 1600);
}
function runFile() {
setGenFile(true);
setTimeout(() => {
@@ -55,8 +44,8 @@ export function ProjectDetail() {
{ label: "项目编号", value: project.code },
{ label: "招标方式", value: project.type },
{ label: "预算金额", value: `${project.budget.toLocaleString()} 万元` },
{ label: "需求部门", value: project.dept },
{ label: "负责人", value: project.owner },
{ label: "委托单位", value: project.dept },
{ label: "委托人", value: project.owner },
{ label: "创建时间", value: project.createdAt },
];
@@ -73,6 +62,9 @@ export function ProjectDetail() {
</div>
<p className="text-sm text-muted-foreground">{project.code}</p>
</div>
<Button onClick={() => toast.success("已提交至 OA 审批流,请在 OA 中查看审批进度")}>
<Send className="size-4" />
</Button>
</div>
{/* 流程总览 */}
@@ -102,48 +94,12 @@ export function ProjectDetail() {
</TabsList>
<TabsContent value="action" className="mt-5 space-y-6">
{/* 招标方案生成 */}
<Card className="gap-0 p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<h3 className="text-foreground"></h3>
<AiBadge label="AI 生成" />
</div>
{!planDone && (
<Button onClick={runPlan} disabled={genPlan}>
{genPlan ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
{genPlan ? "AI 生成中…" : "生成招标方案"}
</Button>
)}
</div>
{planDone ? (
<div className="mt-4 grid grid-cols-3 gap-4">
{[
{ t: "标段划分建议", d: "建议划分为 2 个标段:①控制系统硬件 ②系统集成与服务,便于专业化竞争。" },
{ t: "推荐评标办法", d: "综合评估法:技术 60 分 / 商务 20 分 / 价格 20 分,技术权重较高。" },
{ t: "推荐供应商短名单", d: "基于合格供方库与历史业绩,推荐浙江中控、和利时、西门子等 3 家。" },
].map((c) => (
<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>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{c.d}</p>
</div>
))}
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">
</p>
)}
</Card>
{/* 行动卡片 */}
<div className="grid grid-cols-3 gap-4">
<ActionCard
icon={Sparkles}
title="AI 智慧寻源"
desc="AI 主动外发邀请与回执跟踪"
desc="从互联网 / SRM 库 / 临时供应商池寻源"
onClick={() => navigate(`/app/projects/${project.id}/sourcing`)}
/>
<ActionCard
@@ -155,7 +111,7 @@ export function ProjectDetail() {
<ActionCard
icon={Gavel}
title="开标大厅"
desc="自动开标、解密与哈希上链存证"
desc="在线开标、解密与哈希上链存证"
onClick={() => navigate(`/app/projects/${project.id}/opening`)}
/>
<ActionCard
@@ -164,18 +120,6 @@ export function ProjectDetail() {
desc="专家脱敏评标与 AI 辅助分析"
onClick={() => navigate(`/app/projects/${project.id}/evaluation`)}
/>
<ActionCard
icon={Award}
title="定标审批"
desc="评标报告预览与定标决策"
onClick={() => navigate(`/app/projects/${project.id}/award`)}
/>
<ActionCard
icon={FileSignature}
title="合同生成"
desc="自动拟稿、差异对比与电子签章"
onClick={() => navigate(`/app/projects/${project.id}/contract`)}
/>
<ActionCard
icon={Building2}
title="供应商库"
@@ -184,18 +128,17 @@ export function ProjectDetail() {
/>
</div>
{/* 招标文件 */}
{/* 招标文件(方案与文件合一) */}
<Card className="gap-0 p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<FileText className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
{fileDone && <AiBadge label="AI 生成" />}
</div>
{!fileDone ? (
<Button onClick={runFile} disabled={genFile}>
{genFile ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
{genFile ? "AI 生成文件中…" : "AI 生成文件"}
{genFile ? "AI 生成中…" : "AI 生成招标文件"}
</Button>
) : (
<div className="flex gap-2">
@@ -209,21 +152,37 @@ export function ProjectDetail() {
)}
</div>
{fileDone ? (
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileText className="size-5" />
</div>
<div className="flex-1">
<div className="font-medium text-foreground">{project.name}-.docx</div>
<div className="text-xs text-muted-foreground">AI · 42 · 线</div>
</div>
<Check className="size-5 text-emerald-500" />
<>
<div className="mt-4 grid grid-cols-3 gap-4">
{[
{ t: "标段划分建议", d: "建议划分为 2 个标段:①控制系统硬件 ②系统集成与服务,便于专业化竞争。" },
{ t: "推荐评标办法", d: "综合评估法:技术 60 分 / 商务 20 分 / 价格 20 分,技术权重较高。" },
{ t: "推荐供应商短名单", d: "基于合格供方库与历史业绩,推荐浙江中控、和利时、西门子等 3 家。" },
].map((c) => (
<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-primary" /> {c.t}
</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{c.d}</p>
</div>
))}
</div>
</div>
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileText className="size-5" />
</div>
<div className="flex-1">
<div className="font-medium text-foreground">{project.name}-.docx</div>
<div className="text-xs text-muted-foreground">AI · 42 · 线</div>
</div>
<Check className="size-5 text-emerald-500" />
</div>
</div>
</>
) : (
<p className="mt-3 text-sm text-muted-foreground">
AI 稿线
AI 稿线
</p>
)}
</Card>
+174 -50
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { Plus, Search, Filter, ChevronRight } from "lucide-react";
import { Search, Filter, ChevronRight, ArrowUp, ArrowDown, ChevronsUpDown, SlidersHorizontal, Columns3, Send } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import {
@@ -10,66 +11,184 @@ import {
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { PageHeader, StatusBadge } from "../components/shared/common";
import { projects } from "../data/mock";
type ColKey = "type" | "budget" | "owner" | "dept" | "status" | "progress";
type SortKey = "name" | "budget" | "progress";
const COLUMNS: { key: ColKey; label: string; sortable?: SortKey }[] = [
{ key: "type", label: "招标方式" },
{ key: "budget", label: "预算(万元)", sortable: "budget" },
{ key: "owner", label: "委托人" },
{ key: "dept", label: "委托单位" },
{ key: "status", label: "当前阶段" },
{ key: "progress", label: "进度", sortable: "progress" },
];
export function ProjectList() {
const navigate = useNavigate();
const [q, setQ] = useState("");
const [type, setType] = useState("all");
const [advanced, setAdvanced] = useState(false);
const [owner, setOwner] = useState("");
const [dept, setDept] = useState("");
const [minBudget, setMinBudget] = useState("");
const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" } | null>(null);
const [cols, setCols] = useState<Record<ColKey, boolean>>({
type: true,
budget: true,
owner: true,
dept: true,
status: true,
progress: true,
});
const list = projects.filter(
(p) =>
(type === "all" || p.type === type) &&
(p.name.includes(q) || p.code.includes(q)),
);
const list = useMemo(() => {
let r = projects.filter(
(p) =>
(type === "all" || p.type === type) &&
(p.name.includes(q) || p.code.includes(q)) &&
(!owner || p.owner.includes(owner)) &&
(!dept || p.dept.includes(dept)) &&
(!minBudget || p.budget >= Number(minBudget)),
);
if (sort) {
r = [...r].sort((a, b) => {
let cmp = 0;
if (sort.key === "name") cmp = a.name.localeCompare(b.name, "zh");
else cmp = (a[sort.key] as number) - (b[sort.key] as number);
return sort.dir === "asc" ? cmp : -cmp;
});
}
return r;
}, [q, type, owner, dept, minBudget, sort]);
function toggleSort(key: SortKey) {
setSort((s) =>
s?.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" },
);
}
function SortIcon({ k }: { k: SortKey }) {
if (sort?.key !== k) return <ChevronsUpDown className="size-3.5 text-muted-foreground/50" />;
return sort.dir === "asc" ? <ArrowUp className="size-3.5 text-primary" /> : <ArrowDown className="size-3.5 text-primary" />;
}
return (
<div>
<PageHeader
title="项目管理"
description="管理全部招标项目,覆盖从需求委托到合同归档的全生命周期"
description="管理全部招标项目,覆盖从需求委托到归档的全生命周期"
actions={
<Button onClick={() => navigate("/app/delegation")}>
<Plus className="size-4" />
<Button onClick={() => toast.success("已提交至 OA 审批流,请在 OA 中查看审批进度")}>
<Send className="size-4" />
</Button>
}
/>
<Card className="mb-4 flex-row items-center gap-3 p-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索项目名称或编号"
className="h-9 w-full rounded-lg border bg-input-background pl-9 pr-3 text-sm outline-none focus:border-primary"
/>
<Card className="mb-4 gap-3 p-4">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索项目名称或编号"
className="h-9 w-full rounded-lg border bg-input-background pl-9 pr-3 text-sm outline-none focus:border-primary"
/>
</div>
<Select value={type} onValueChange={setType}>
<SelectTrigger className="w-40">
<Filter className="size-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="公开招标"></SelectItem>
<SelectItem value="邀请招标"></SelectItem>
<SelectItem value="竞争性谈判"></SelectItem>
</SelectContent>
</Select>
<Button variant={advanced ? "default" : "outline"} onClick={() => setAdvanced((v) => !v)}>
<SlidersHorizontal className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Columns3 className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
{COLUMNS.map((c) => (
<DropdownMenuCheckboxItem
key={c.key}
checked={cols[c.key]}
onCheckedChange={(v) => setCols((s) => ({ ...s, [c.key]: !!v }))}
onSelect={(e) => e.preventDefault()}
>
{c.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Select value={type} onValueChange={setType}>
<SelectTrigger className="w-40">
<Filter className="size-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="公开招标"></SelectItem>
<SelectItem value="邀请招标"></SelectItem>
<SelectItem value="竞争性谈判"></SelectItem>
</SelectContent>
</Select>
{advanced && (
<div className="grid grid-cols-4 gap-3 rounded-lg border bg-muted/20 p-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground"></label>
<input value={owner} onChange={(e) => setOwner(e.target.value)} placeholder="按委托人筛选" className="h-9 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary" />
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground"></label>
<input value={dept} onChange={(e) => setDept(e.target.value)} placeholder="按委托单位筛选" className="h-9 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary" />
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">()</label>
<input value={minBudget} onChange={(e) => setMinBudget(e.target.value.replace(/\D/g, ""))} placeholder="如 500" className="h-9 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary" />
</div>
<div className="flex items-end">
<Button variant="outline" className="h-9" onClick={() => { setOwner(""); setDept(""); setMinBudget(""); }}>
</Button>
</div>
</div>
)}
</Card>
<Card className="gap-0 overflow-hidden p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-5 py-3 font-medium"> / </th>
<th className="px-5 py-3 font-medium"></th>
<th className="px-5 py-3 font-medium">()</th>
<th className="px-5 py-3 font-medium"></th>
<th className="px-5 py-3 font-medium"></th>
<th className="px-5 py-3 font-medium"></th>
<th className="px-5 py-3 font-medium">
<button onClick={() => toggleSort("name")} className="flex items-center gap-1 hover:text-foreground">
/ <SortIcon k="name" />
</button>
</th>
{COLUMNS.map((c) =>
cols[c.key] ? (
<th key={c.key} className="px-5 py-3 font-medium">
{c.sortable ? (
<button onClick={() => toggleSort(c.sortable!)} className="flex items-center gap-1 hover:text-foreground">
{c.label} <SortIcon k={c.sortable} />
</button>
) : (
c.label
)}
</th>
) : null,
)}
<th className="px-5 py-3" />
</tr>
</thead>
@@ -84,20 +203,25 @@ export function ProjectList() {
<div className="font-medium text-foreground">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.code}</div>
</td>
<td className="px-5 py-4 text-muted-foreground">{p.type}</td>
<td className="px-5 py-4 font-medium text-foreground">{p.budget.toLocaleString()}</td>
<td className="px-5 py-4 text-muted-foreground">{p.owner}</td>
<td className="px-5 py-4">
<StatusBadge status={p.status} />
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${p.progress}%` }} />
{cols.type && <td className="px-5 py-4 text-muted-foreground">{p.type}</td>}
{cols.budget && <td className="px-5 py-4 font-medium text-foreground">{p.budget.toLocaleString()}</td>}
{cols.owner && <td className="px-5 py-4 text-muted-foreground">{p.owner}</td>}
{cols.dept && <td className="px-5 py-4 text-muted-foreground">{p.dept}</td>}
{cols.status && (
<td className="px-5 py-4">
<StatusBadge status={p.status} />
</td>
)}
{cols.progress && (
<td className="px-5 py-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${p.progress}%` }} />
</div>
<span className="text-xs text-muted-foreground">{p.progress}%</span>
</div>
<span className="text-xs text-muted-foreground">{p.progress}%</span>
</div>
</td>
</td>
)}
<td className="px-5 py-4 text-right">
<ChevronRight className="size-4 text-muted-foreground" />
</td>
+83 -15
View File
@@ -1,15 +1,41 @@
import { useNavigate } from "react-router";
import { ArrowLeft, Sparkles, Send, Eye, Check, Globe } from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { ArrowLeft, Sparkles, Send, Eye, Check, Globe, Database, Boxes } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { AiBadge } from "../components/shared/common";
import { sourcingRecords } from "../data/mock";
import { sourcingRecords, projects } from "../data/mock";
type SourceKey = "all" | "internet" | "srm" | "pool";
const SOURCES: { key: SourceKey; label: string; icon: any; desc: string }[] = [
{ key: "all", label: "全部来源", icon: Sparkles, desc: "AI 综合多渠道寻源" },
{ key: "internet", label: "互联网寻源", icon: Globe, desc: "公共资源交易平台等" },
{ key: "srm", label: "SRM 库寻源", icon: Database, desc: "集团合格供方库" },
{ key: "pool", label: "临时供应商池", icon: Boxes, desc: "STP 平台临时池" },
];
// 依据记录派生来源(示例):按序分配到三类来源
function sourceOf(index: number): Exclude<SourceKey, "all"> {
return (["internet", "srm", "pool"] as const)[index % 3];
}
const SOURCE_LABEL: Record<Exclude<SourceKey, "all">, string> = {
internet: "互联网",
srm: "SRM 库",
pool: "临时池",
};
export function SmartSourcing() {
const navigate = useNavigate();
const viewed = sourcingRecords.filter((r) => r.viewed).length;
const registered = sourcingRecords.filter((r) => r.registered).length;
const { id } = useParams();
const project = projects.find((p) => p.id === id) ?? projects[0];
const [tab, setTab] = useState<SourceKey>("all");
const records = sourcingRecords.map((r, i) => ({ ...r, source: sourceOf(i) }));
const filtered = tab === "all" ? records : records.filter((r) => r.source === tab);
const viewed = filtered.filter((r) => r.viewed).length;
const registered = filtered.filter((r) => r.registered).length;
return (
<div>
@@ -17,15 +43,50 @@ export function SmartSourcing() {
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="size-5" />
</Button>
<div className="flex items-center gap-2">
<h1 className="text-foreground">AI </h1>
<AiBadge label="AI 主动寻源" />
<div className="flex-1">
<div className="flex items-center gap-2">
<h1 className="text-foreground">AI </h1>
<AiBadge label="AI 主动寻源" />
</div>
{/* 关联项目 */}
<p className="mt-0.5 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{project.name}</span> · {project.code}
</p>
</div>
</div>
{/* 三类寻源来源 */}
<div className="mb-6 grid grid-cols-4 gap-4">
{SOURCES.map((s) => {
const Icon = s.icon;
const active = tab === s.key;
const count = s.key === "all" ? records.length : records.filter((r) => r.source === s.key).length;
return (
<button
key={s.key}
onClick={() => setTab(s.key)}
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
active ? "border-primary bg-secondary/60 shadow-sm" : "bg-card hover:border-primary/50"
}`}
>
<div className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${active ? "bg-primary text-white" : "bg-primary/8 text-primary"}`}>
<Icon className="size-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{s.label}</span>
<span className="rounded-full bg-primary/10 px-1.5 text-xs text-primary">{count}</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{s.desc}</p>
</div>
</button>
);
})}
</div>
<div className="mb-6 grid grid-cols-3 gap-4">
{[
{ l: "AI 外发邀请", v: sourcingRecords.length, c: "#2563eb" },
{ l: "AI 外发邀请", v: filtered.length, c: "var(--primary)" },
{ l: "已查看回执", v: viewed, c: "#f59e0b" },
{ l: "已报名", v: registered, c: "#14b8a6" },
].map((s) => (
@@ -38,13 +99,17 @@ export function SmartSourcing() {
<Card className="gap-0 p-0">
<div className="flex items-center gap-2 border-b px-6 py-4">
<Globe className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
<Sparkles className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{SOURCES.find((s) => s.key === tab)?.label}
</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-6 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
@@ -53,9 +118,12 @@ export function SmartSourcing() {
</tr>
</thead>
<tbody className="divide-y">
{sourcingRecords.map((r) => (
{filtered.map((r) => (
<tr key={r.id} className={`hover:bg-muted/20 ${!r.viewed ? "bg-amber-50/30" : ""}`}>
<td className="px-6 py-3 font-medium text-foreground">{r.supplier}</td>
<td className="px-4 py-3">
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-primary">{SOURCE_LABEL[r.source]}</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{r.channel}</td>
<td className="px-4 py-3 text-muted-foreground">{r.sentAt}</td>
<td className="px-4 py-3">
@@ -79,9 +147,9 @@ export function SmartSourcing() {
</table>
</Card>
<div className="mt-4 flex items-start gap-2 rounded-lg border border-violet-200 bg-violet-50/50 p-3 text-sm text-muted-foreground">
<Sparkles className="mt-0.5 size-4 shrink-0 text-violet-500" />
<div className="mt-4 flex items-start gap-2 rounded-lg border border-primary/20 bg-secondary/40 p-3 text-sm text-muted-foreground">
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
SRM STP
</div>
</div>
);
@@ -1,89 +0,0 @@
import { useState } from "react";
import { PackagePlus, Sparkles, ArrowRight, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { PageHeader, RiskBadge } from "../components/shared/common";
import { suppliers } from "../data/mock";
type Status = "待发起" | "审批中" | "已入库";
export function SupplierInbound() {
const [rows, setRows] = useState(
suppliers.slice(0, 5).map((s, i) => ({
...s,
inbound: (i === 0 ? "已入库" : i === 1 ? "审批中" : "待发起") as Status,
})),
);
function advance(id: string) {
setRows((r) =>
r.map((x) =>
x.id === id ? { ...x, inbound: x.inbound === "待发起" ? "审批中" : "已入库" } : x,
),
);
toast.success("已发起供应商入库流程,自动推送至 SRM 系统");
}
const statusColor: Record<Status, string> = {
: "bg-slate-100 text-slate-600",
: "bg-amber-100 text-amber-700",
: "bg-emerald-100 text-emerald-700",
};
return (
<div>
<PageHeader title="供应商入库管理" description="定标后触发的供应商入库流程,审批通过自动同步至 SRM" />
<div className="mb-4 flex items-start gap-2 rounded-xl border border-violet-200 bg-violet-50/50 p-4 text-sm text-muted-foreground">
<Sparkles className="mt-0.5 size-4 shrink-0 text-violet-500" />
SRM
</div>
<Card className="gap-0 overflow-hidden p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-5 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((s) => (
<tr key={s.id} className="hover:bg-muted/20">
<td className="px-5 py-3 font-medium text-foreground">{s.name}</td>
<td className="px-4 py-3 text-muted-foreground">{s.category}</td>
<td className="px-4 py-3">
<RiskBadge risk={s.risk} />
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs ${statusColor[s.inbound]}`}>{s.inbound}</span>
</td>
<td className="px-4 py-3">
{s.inbound === "待发起" && (
<Button size="sm" className="h-7 gap-1" onClick={() => advance(s.id)}>
<PackagePlus className="size-3.5" />
</Button>
)}
{s.inbound === "审批中" && (
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => advance(s.id)}>
<ArrowRight className="size-3.5" />
</Button>
)}
{s.inbound === "已入库" && (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check className="size-4" /> SRM
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
);
}
+1 -15
View File
@@ -1,17 +1,13 @@
import { useState } from "react";
import {
Plus,
Search,
Filter,
PackagePlus,
Building2,
Phone,
Star,
Sparkles,
TrendingUp,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import {
Sheet,
@@ -42,17 +38,7 @@ export function SupplierManagement() {
<div>
<PageHeader
title="供应商管理"
description="管理已注册供应商,工商风险信息由 AI 自动同步"
actions={
<div className="flex gap-2">
<Button variant="outline" onClick={() => toast.success("已发起供应商入库流程")}>
<PackagePlus className="size-4" />
</Button>
<Button onClick={() => toast.success("新增供应商")}>
<Plus className="size-4" />
</Button>
</div>
}
description="管理已注册供应商,资质审核通过后系统自动入库,工商风险信息由 AI 自动同步"
/>
<Card className="mb-4 flex-row items-center gap-3 p-4">
+98 -317
View File
@@ -1,36 +1,23 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import {
ArrowLeft,
Check,
X,
Trash2,
PackagePlus,
ChevronDown,
Sparkles,
FileCheck2,
Loader2,
Megaphone,
Send,
} from "lucide-react";
import { ArrowLeft, Check, X, Trash2, PackagePlus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../components/ui/tabs";
import {
PageHeader,
AiResultTag,
RiskBadge,
ApprovalTracker,
} from "../components/shared/common";
import { PageHeader } from "../components/shared/common";
import { suppliers } from "../data/mock";
// 招标方式已在需求委托阶段确定,故本页不再区分"邀请/公开"分支,直接进行长短名单管理。
export function SupplierShortlist() {
const navigate = useNavigate();
const [longList, setLongList] = useState(
suppliers.map((s) => ({ ...s, decision: "" as "" | "pass" | "reject" })),
);
const shortList = longList.filter((s) => s.decision === "pass");
return (
<div>
<div className="mb-1 flex items-center gap-3">
<div className="mb-4 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="size-5" />
</Button>
@@ -40,154 +27,54 @@ export function SupplierShortlist() {
/>
</div>
<Tabs defaultValue="invite">
<TabsList>
<TabsTrigger value="invite"></TabsTrigger>
<TabsTrigger value="public"></TabsTrigger>
</TabsList>
<TabsContent value="invite" className="mt-5">
<InviteBranch />
</TabsContent>
<TabsContent value="public" className="mt-5">
<PublicBranch />
</TabsContent>
</Tabs>
</div>
);
}
/* ---------------- 邀请招标:长名单 / 短名单 ---------------- */
function InviteBranch() {
const [longList, setLongList] = useState(
suppliers.map((s) => ({ ...s, decision: "" as "" | "pass" | "reject" })),
);
const shortList = longList.filter((s) => s.decision === "pass");
return (
<div className="space-y-6">
{/* 长名单 */}
<Card className="gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{longList.length}
</span>
<div className="space-y-6">
{/* 长名单 */}
<Card className="gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{longList.length}
</span>
</div>
<span className="text-sm text-muted-foreground"></span>
</div>
<span className="text-sm text-muted-foreground"></span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-6 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{longList.map((s, i) => (
<tr key={s.id} className="hover:bg-muted/20">
<td className="px-6 py-3 font-medium text-foreground">{s.name}</td>
<td className="px-4 py-3 text-muted-foreground">{s.contact}</td>
<td className="px-4 py-3 text-muted-foreground">{s.dept}</td>
<td className="px-4 py-3 text-muted-foreground">{s.opinion}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<Button
size="sm"
variant={s.decision === "pass" ? "default" : "outline"}
className="h-7 gap-1 px-2"
onClick={() =>
setLongList((l) =>
l.map((x, j) => (j === i ? { ...x, decision: "pass" } : x)),
)
}
>
<Check className="size-3.5" />
</Button>
<Button
size="sm"
variant={s.decision === "reject" ? "destructive" : "outline"}
className="h-7 gap-1 px-2"
onClick={() =>
setLongList((l) =>
l.map((x, j) => (j === i ? { ...x, decision: "reject" } : x)),
)
}
>
<X className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</Card>
{/* 短名单 */}
<Card className="gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{shortList.length}
</span>
</div>
<Button size="sm" disabled={shortList.length === 0} onClick={() => toast.success("短名单已确认,进入邀请阶段")}>
</Button>
</div>
{shortList.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<PackagePlus className="size-8 text-muted-foreground/40" />
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-6 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{shortList.map((s) => (
{longList.map((s, i) => (
<tr key={s.id} className="hover:bg-muted/20">
<td className="px-6 py-3 font-medium text-foreground">{s.name}</td>
<td className="px-4 py-3">
{s.inLibrary ? (
<span className="inline-flex items-center gap-1 text-emerald-600">
<Check className="size-4" />
</span>
) : (
<span className="text-amber-600"></span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{s.conclusion}</td>
<td className="px-4 py-3 text-muted-foreground">{s.contact}</td>
<td className="px-4 py-3 text-muted-foreground">{s.opinion}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{!s.inLibrary && (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2" onClick={() => toast.success("已发起入库流程")}>
<PackagePlus className="size-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
variant={s.decision === "pass" ? "default" : "outline"}
className="h-7 gap-1 px-2"
onClick={() =>
setLongList((l) =>
l.map((x) => (x.id === s.id ? { ...x, decision: "" } : x)),
)
setLongList((l) => l.map((x, j) => (j === i ? { ...x, decision: "pass" } : x)))
}
>
<Trash2 className="size-3.5" />
<Check className="size-3.5" />
</Button>
<Button
size="sm"
variant={s.decision === "reject" ? "destructive" : "outline"}
className="h-7 gap-1 px-2"
onClick={() =>
setLongList((l) => l.map((x, j) => (j === i ? { ...x, decision: "reject" } : x)))
}
>
<X className="size-3.5" />
</Button>
</div>
</td>
@@ -195,175 +82,69 @@ function InviteBranch() {
))}
</tbody>
</table>
)}
</Card>
</div>
);
}
</Card>
/* ---------------- 公开招标:寻源公告 + 报名审核 ---------------- */
function PublicBranch() {
return (
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2 space-y-6">
<AnnouncementEditor />
<RegistrationReview />
</div>
<div className="space-y-6">
<Card className="gap-0 p-6">
<h4 className="mb-4 text-foreground"></h4>
<ApprovalTracker
nodes={[
{ role: "经办人提交", name: "李娜 · 招标专员", status: "completed", time: "06-18 10:00" },
{ role: "部门负责人审批", name: "安全环保部 · 已通过", status: "completed", time: "06-18 15:20" },
{ role: "合规审批", name: "法务合规 · 审批中", status: "current" },
{ role: "公告发布", name: "对外发布寻源公告", status: "pending" },
]}
/>
{/* 短名单 */}
<Card className="gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{shortList.length}
</span>
</div>
<Button size="sm" disabled={shortList.length === 0} onClick={() => toast.success("短名单已确认,进入邀请阶段")}>
</Button>
</div>
{shortList.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<PackagePlus className="size-8 text-muted-foreground/40" />
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-left text-muted-foreground">
<th className="px-6 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{shortList.map((s) => (
<tr key={s.id} className="hover:bg-muted/20">
<td className="px-6 py-3 font-medium text-foreground">{s.name}</td>
<td className="px-4 py-3">
{s.inLibrary ? (
<span className="inline-flex items-center gap-1 text-emerald-600">
<Check className="size-4" />
</span>
) : (
<span className="text-amber-600"></span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{s.conclusion}</td>
<td className="px-4 py-3">
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
onClick={() =>
setLongList((l) => l.map((x) => (x.id === s.id ? { ...x, decision: "" } : x)))
}
>
<Trash2 className="size-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
</div>
);
}
function AnnouncementEditor() {
return (
<Card className="gap-0 p-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-foreground"></h3>
<Button size="sm" variant="outline">
<Sparkles className="size-4" /> AI
</Button>
</div>
{/* 富文本工具栏 */}
<div className="flex items-center gap-1 rounded-t-lg border border-b-0 bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{["B", "I", "U", "H1", "H2", "•", "1.", "🔗"].map((b) => (
<button key={b} className="rounded px-2 py-1 hover:bg-muted">
{b}
</button>
))}
</div>
<div className="rounded-b-lg border bg-card p-4 text-sm leading-relaxed text-foreground">
<p className="font-medium"></p>
<p className="mt-3 text-muted-foreground">
</p>
<p className="mt-3 font-medium"></p>
<p className="text-muted-foreground">
A 180
</p>
<p className="mt-3 font-medium"></p>
<p className="text-muted-foreground">
2
</p>
<p className="mt-3 font-medium"></p>
<p className="text-muted-foreground">2026 6 25 2026 7 5 9:00-17:00</p>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline">稿</Button>
<Button onClick={() => toast.success("公告已提交审批")}>
<Send className="size-4" />
</Button>
</div>
</Card>
);
}
function RegistrationReview() {
const applicants = suppliers.slice(0, 4);
const [expanded, setExpanded] = useState<string | null>(applicants[0].id);
const [approved, setApproved] = useState<string[]>([]);
return (
<Card className="gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<Megaphone className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{applicants.length}
</span>
</div>
<span className="text-sm text-muted-foreground">
{approved.length}
</span>
</div>
<div className="divide-y">
{applicants.map((s) => {
const open = expanded === s.id;
const isApproved = approved.includes(s.id);
return (
<div key={s.id}>
<button
className="flex w-full items-center gap-3 px-6 py-4 text-left hover:bg-muted/20"
onClick={() => setExpanded(open ? null : s.id)}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{s.name}</span>
{s.aiResult && <AiResultTag result={s.aiResult} />}
{isApproved && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-600"></span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{s.contact} · {s.phone}
</div>
</div>
<RiskBadge risk={s.risk} />
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="bg-muted/20 px-6 py-4">
<div className="mb-3 text-sm font-medium text-foreground"></div>
<div className="grid grid-cols-3 gap-3">
{["营业执照.pdf", "环保工程资质证书.pdf", "近三年业绩证明.pdf"].map((f) => (
<div key={f} className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-sm">
<FileCheck2 className="size-4 text-primary" />
<span className="flex-1 truncate text-foreground">{f}</span>
</div>
))}
</div>
{/* AI 审核结果 */}
<div className="mt-3 rounded-lg border border-violet-200 bg-violet-50/60 p-3">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Sparkles className="size-4 text-violet-500" /> AI
</div>
<p className="mt-1.5 text-sm text-muted-foreground">
{s.aiResult === "资质有效"
? "资质文件齐全有效,工商信息正常,未发现关联投标及失信风险,建议通过。"
: "检测到该供应商与其他报名单位存在股权关联,建议人工进一步核实后决定。"}
</p>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button size="sm" variant="outline" className="gap-1">
<Sparkles className="size-3.5" /> AI
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => toast.error("已驳回该供应商报名")}
>
</Button>
<Button
size="sm"
disabled={isApproved}
onClick={() => {
setApproved((a) => [...a, s.id]);
toast.success(`${s.name} 审核通过,已进入报名通过名单`);
}}
>
<Check className="size-3.5" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</Card>
);
}
+46 -40
View File
@@ -2,21 +2,20 @@ import { useNavigate } from "react-router";
import {
ChevronRight,
Sparkles,
GitMerge,
Bot,
Activity,
ShieldAlert,
Network,
TrendingUp,
Gavel,
CheckCircle2,
FileText,
FilePlus2,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { AiBadge } from "../components/shared/common";
import { AgentTaskMonitor } from "../components/AgentTaskMonitor";
import { useRole } from "../role-context";
import { WORKBENCH } from "../data/workbench";
import { mergeCandidates, riskWarnings, bidProjects, approvals } from "../data/mock";
import { riskWarnings, bidProjects } from "../data/mock";
const priorityStyle: Record<string, string> = {
: "bg-red-50 text-red-600",
@@ -69,7 +68,7 @@ export function Workbench() {
<Card className="col-span-2 gap-0 p-0">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<h3 className="text-foreground"></h3>
<h3 className="text-foreground"></h3>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{totalTodo} </span>
</div>
</div>
@@ -113,6 +112,13 @@ export function Workbench() {
{/* 角色专属附加区 */}
<div className="space-y-6">{wb.extra && <RoleExtra extra={wb.extra} />}</div>
</div>
{/* 后台智能体任务监控(招标专员 / 系统管理员) */}
{wb.agentMonitor && (
<div className="mt-6">
<AgentTaskMonitor />
</div>
)}
</div>
);
}
@@ -124,24 +130,47 @@ function RoleExtra({ extra }: { extra: string }) {
return (
<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-primary" />
<h4 className="text-foreground"></h4>
<Bot className="size-4 text-primary" />
<h4 className="text-foreground">AI </h4>
<AiBadge label="AI" />
</div>
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{mergeCandidates.length}</span> ~15
<span className="font-semibold text-foreground">2</span>
</p>
<div className="mt-3 space-y-2">
{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-primary"> {m.overlap}%</span>
<div className="mt-3 space-y-2 text-sm">
{[
{ l: "🔍 AI 智慧寻源", v: "70%" },
{ l: "🔬 AI 雷同检测", v: "30%" },
].map((x) => (
<div key={x.l} className="flex items-center justify-between rounded-lg border bg-card px-3 py-2">
<span className="line-clamp-1 flex-1 text-foreground">{x.l}</span>
<span className="ml-2 shrink-0 text-xs text-amber-600">{x.v}</span>
</div>
))}
</div>
<Button className="mt-4 w-full" onClick={() => navigate("/app/merge")}>
</Button>
</Card>
);
}
if (extra === "requester") {
return (
<Card className="gap-0 border-primary/20 bg-secondary/50 p-5">
<div className="mb-2 flex items-center gap-2">
<FilePlus2 className="size-4 text-primary" />
<h4 className="text-foreground"></h4>
<AiBadge label="AI" />
</div>
<p className="text-sm text-muted-foreground">
AI
</p>
<div className="mt-4 space-y-2">
<Button className="w-full" onClick={() => navigate("/app/delegation")}>
<FilePlus2 className="size-4" />
</Button>
<Button variant="outline" className="w-full" onClick={() => navigate("/app/projects")}>
</Button>
</div>
</Card>
);
}
@@ -176,29 +205,6 @@ function RoleExtra({ extra }: { extra: string }) {
);
}
if (extra === "approver") {
return (
<Card className="gap-0 p-5">
<h4 className="mb-3 text-foreground"></h4>
<div className="space-y-2.5">
{approvals.slice(0, 3).map((a) => (
<button
key={a.id}
onClick={() => navigate("/app/approvals")}
className="w-full rounded-lg border bg-card p-3 text-left hover:border-primary"
>
<div className="flex items-center gap-2">
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-600">{a.type}</span>
{a.urgent && <span className="rounded bg-red-50 px-1.5 py-0.5 text-xs text-red-600"></span>}
</div>
<div className="mt-1 line-clamp-1 text-sm text-foreground">{a.project}</div>
</button>
))}
</div>
</Card>
);
}
if (extra === "admin") {
return (
<Card className="gap-0 p-5">
@@ -1,104 +0,0 @@
import { Workflow, Plus, Settings, ArrowRight, History, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { PageHeader } from "../../components/shared/common";
const nodes = [
{ name: "经办人提交", rule: "招标专员发起" },
{ name: "部门负责人", rule: "本部门负责人" },
{ name: "分管领导", rule: "金额 ≥ 500 万" },
{ name: "总经理审批", rule: "金额 ≥ 2000 万" },
];
const versions = [
{ v: "v3.2", date: "2026-06-01", status: "当前版本", by: "运维管理员" },
{ v: "v3.1", date: "2026-04-15", status: "历史", by: "运维管理员" },
{ v: "v3.0", date: "2026-01-10", status: "历史", by: "运维管理员" },
];
export function ProcessConfig() {
return (
<div>
<PageHeader
title="流程配置中心"
description="可视化配置审批流程节点、审批人规则与版本管理"
actions={
<Button onClick={() => toast.success("已发布新流程版本")}></Button>
}
/>
<div className="grid grid-cols-3 gap-6">
<Card className="col-span-2 gap-0 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Workflow className="size-5 text-primary" />
<h3 className="text-foreground"></h3>
</div>
<Button size="sm" variant="outline">
<Plus className="size-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-dashed bg-muted/20 p-6">
{nodes.map((n, i) => (
<div key={n.name} className="flex items-center gap-2">
<div className="w-36 rounded-lg border bg-card p-3 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{n.name}</span>
<Settings className="size-3.5 cursor-pointer text-muted-foreground hover:text-primary" />
</div>
<div className="mt-1 text-xs text-muted-foreground">{n.rule}</div>
</div>
{i < nodes.length - 1 && <ArrowRight className="size-4 shrink-0 text-muted-foreground" />}
</div>
))}
</div>
<div className="mt-5">
<h4 className="mb-3 text-foreground"></h4>
<div className="space-y-2">
{[
["金额 < 500 万", "部门负责人审批"],
["500 万 ≤ 金额 < 2000 万", "部门负责人 + 分管领导"],
["金额 ≥ 2000 万", "部门负责人 + 分管领导 + 总经理"],
].map(([cond, route]) => (
<div key={cond} className="flex items-center justify-between rounded-lg border bg-card px-4 py-2.5 text-sm">
<span className="text-muted-foreground">{cond}</span>
<span className="font-medium text-foreground">{route}</span>
</div>
))}
</div>
</div>
</Card>
<Card className="gap-0 p-6">
<div className="mb-3 flex items-center gap-2">
<History className="size-5 text-primary" />
<h4 className="text-foreground"></h4>
</div>
<div className="space-y-2">
{versions.map((v) => (
<div key={v.v} className="flex items-center justify-between rounded-lg border bg-card p-3">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{v.v}</span>
{v.status === "当前版本" && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-600">{v.status}</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{v.date} · {v.by}</div>
</div>
{v.status !== "当前版本" && (
<Button size="sm" variant="ghost" className="h-7 gap-1" onClick={() => toast.success(`已回滚至 ${v.v}`)}>
<RotateCcw className="size-3.5" />
</Button>
)}
</div>
))}
</div>
</Card>
</div>
</div>
);
}
+38 -60
View File
@@ -1,12 +1,16 @@
// 滨化集团智慧招标平台 (STP) - 多角色配置 V1.3
// 滨化集团智慧招标平台 (STP) - 多角色配置 V1.4
// 说明:依据内部评审意见调整——
// 1)统一科技蓝主题(呼应滨化 LOGO);
// 2)合并"需求部门人员/采购专员"为"采购需求人员"
// 3)移除"审批人"(审批在 OA 中体现);
// 4)发起招标委托归属"采购需求人员",招标专员不含该功能;
// 5)移除合并招标、供应商入库、合同管理、审批中心、流程配置等入口。
export type RoleId =
| "requester"
| "specialist"
| "purchaser"
| "expert"
| "committee"
| "approver"
| "supplier"
| "admin"
| "auditor";
@@ -23,24 +27,31 @@ export interface RoleConfig {
name: string; // 角色名
user: string; // 示例用户/企业名
dept: string;
color: string; // 辅助色
color: string; // 辅助色(统一科技蓝)
bannerFrom: string;
bannerTo: string;
nav: NavItem[];
}
// 全平台统一科技蓝
const BRAND_FROM = "#0d47a1";
const BRAND_TO = "#1e88e5";
const BRAND_DOT = "#1565c0";
export const ROLES: Record<RoleId, RoleConfig> = {
requester: {
id: "requester",
name: "需求部门人员",
name: "采购需求人员",
user: "刘晓敏",
dept: "氯碱生产部",
color: "#0ea5e9",
bannerFrom: "#0c4a6e",
bannerTo: "#0ea5e9",
dept: "氯碱生产部 / 采购供应部",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/delegation", label: "发起委托", icon: "FilePlus2" },
{ to: "/app/projects", label: "我的项目", icon: "FolderKanban" },
{ to: "/app/deposits", label: "保证金管理", icon: "Wallet" },
],
},
specialist: {
@@ -48,43 +59,26 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "招标专员",
user: "张伟",
dept: "招标采购中心",
color: "#00695c",
bannerFrom: "#00695c",
bannerTo: "#00897b",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/projects", label: "项目管理", icon: "FolderKanban" },
{ to: "/app/merge", label: "合并招标", icon: "GitMerge" },
{ to: "/app/suppliers", label: "供应商管理", icon: "Building2" },
{ to: "/app/experts", label: "专家管理", icon: "UserCog" },
{ to: "/app/deposits", label: "保证金管理", icon: "Wallet" },
{ to: "/app/reports", label: "报表中心", icon: "BarChart3" },
],
},
purchaser: {
id: "purchaser",
name: "采购专员",
user: "陈晨",
dept: "采购供应部",
color: "#6366f1",
bannerFrom: "#312e81",
bannerTo: "#6366f1",
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/projects", label: "我的项目", icon: "FolderKanban" },
{ to: "/app/contracts", label: "合同管理", icon: "FileSignature" },
{ to: "/app/inbound", label: "供应商入库", icon: "PackagePlus" },
{ to: "/app/deposits", label: "保证金管理", icon: "Wallet" },
],
},
expert: {
id: "expert",
name: "评标专家",
user: "郑国华",
dept: "外部专家 · 化工工艺",
color: "#1565c0",
bannerFrom: "#0d47a1",
bannerTo: "#1976d2",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/expert-tasks", label: "评标任务", icon: "Gavel" },
@@ -95,35 +89,22 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "评标委员会工作领导小组",
user: "孙建华",
dept: "评标委员会",
color: "#dc2626",
bannerFrom: "#7f1d1d",
bannerTo: "#dc2626",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/exceptions", label: "异常决策", icon: "ShieldAlert" },
],
},
approver: {
id: "approver",
name: "审批人",
user: "王立强",
dept: "分管副总",
color: "#10b981",
bannerFrom: "#064e3b",
bannerTo: "#10b981",
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/approvals", label: "待审批", icon: "ClipboardCheck" },
],
},
supplier: {
id: "supplier",
name: "供应商",
user: "浙江中控技术股份有限公司",
dept: "投标用户",
color: "#c2185b",
bannerFrom: "#c2185b",
bannerTo: "#e91e63",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/bidding", label: "投标中心", icon: "PackageOpen" },
@@ -134,13 +115,12 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "系统管理员",
user: "运维管理员",
dept: "信息中心",
color: "#475569",
bannerFrom: "#1e293b",
bannerTo: "#475569",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/admin/users", label: "用户与权限", icon: "Users" },
{ to: "/app/admin/process", label: "流程配置", icon: "Workflow" },
{ to: "/app/admin/keys", label: "密钥管理", icon: "KeyRound" },
{ to: "/app/suppliers", label: "供应商管理", icon: "Building2" },
{ to: "/app/experts", label: "专家管理", icon: "UserCog" },
@@ -153,9 +133,9 @@ export const ROLES: Record<RoleId, RoleConfig> = {
name: "合规 / 审计",
user: "李审计",
dept: "审计监察部",
color: "#0d9488",
bannerFrom: "#134e4a",
bannerTo: "#0d9488",
color: BRAND_DOT,
bannerFrom: BRAND_FROM,
bannerTo: BRAND_TO,
nav: [
{ to: "/app", label: "工作台", icon: "LayoutDashboard", end: true },
{ to: "/app/audit-logs", label: "审计日志", icon: "ScrollText" },
@@ -169,10 +149,8 @@ export const ROLES: Record<RoleId, RoleConfig> = {
export const ROLE_ORDER: RoleId[] = [
"specialist",
"requester",
"purchaser",
"expert",
"committee",
"approver",
"supplier",
"admin",
"auditor",
+20 -20
View File
@@ -8,14 +8,14 @@
--card-foreground: #1a2332;
--popover: #ffffff;
--popover-foreground: #1a2332;
--primary: #c2185b;
--primary: #1565c0;
--primary-foreground: #ffffff;
--secondary: #fce4ec;
--secondary-foreground: #ad1457;
--secondary: #e3f2fd;
--secondary-foreground: #0d47a1;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #fce4ec;
--accent-foreground: #ad1457;
--accent: #e3f2fd;
--accent-foreground: #0d47a1;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
@@ -24,24 +24,24 @@
--switch-background: #cbd5e1;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: #c2185b;
--chart-1: #c2185b;
--chart-2: #1565c0;
--chart-3: #00897b;
--chart-4: #f59e0b;
--chart-5: #9c27b0;
--ring: #1565c0;
--chart-1: #1565c0;
--chart-2: #00897b;
--chart-3: #f59e0b;
--chart-4: #7e57c2;
--chart-5: #ef5350;
--radius: 0.75rem;
/* 品牌渐变(全平台统一): #c2185b → #e91e63 */
--brand-from: #c2185b;
--brand-to: #e91e63;
--sidebar: #2d0a19;
--sidebar-foreground: #f4d9e4;
--sidebar-primary: #e91e63;
/* 品牌渐变(全平台统一科技蓝,呼应滨化 LOGO): #0d47a1 → #1e88e5 */
--brand-from: #0d47a1;
--brand-to: #1e88e5;
--sidebar: #0d2137;
--sidebar-foreground: #d6e4f5;
--sidebar-primary: #1e88e5;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #6d1338;
--sidebar-accent: #16375a;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #4a0e26;
--sidebar-ring: #e91e63;
--sidebar-border: #16375a;
--sidebar-ring: #1e88e5;
}
.dark {