Compare commits

..

13 Commits

Author SHA1 Message Date
vame 3dba6db9d2 feat(frontend): 移除万华相关元素并更新门户登录展示
统一清理前端万华相关文案与公司展示口径为滨化集团,移除登录与门户页角色选框,并同步更新全量页面截图及Word汇总文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:48:44 +08:00
vame 41fc6e86dd feat(frontend): 对齐万华门户风格并更新全量截图文档
- 新增门户导航子页面(交易信息、政策法规、操作指南、常见问题、采购平台)并统一门户壳层样式
- 按万华站点风格重构首页与登录页(导航、轮播、公告、登录区)
- 优化小滨助手:首页/登录页接入、可拖拽吸边、圆形图标、圆形外框与切换动画优化
- 重新生成全量页面截图并更新《滨化智慧招标平台-前端界面截图汇总.docx》

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:35:20 +08:00
weiwen_ye aa023a6d45 Merge branch 'main' of https://101.132.22.89:3000/Dubhe/STP 2026-07-11 09:43:50 +08:00
weiwen_ye e0ae0e4007 移动 review 文件夹从 docs/architecture 到 docs 下 2026-07-11 09:41:29 +08:00
vame d81ac936e7 feat(front-end): 依据内部评审意见对前端 UI 进行二次升级优化
- 主题色统一改回科技蓝(呼应滨化 LOGO)
- 合并"采购需求人员"、移除审批人角色(审批在 OA)
- 发起委托归属采购需求人员,招标专员不含该功能
- 删除合并招标/定标审批/合同生成/合同管理/供应商入库/审批中心/流程配置页面
- 小滨助手默认展开可收起、各角色主动提醒、问答直接给出按钮/链接
- 待办中心去掉"事找人"前缀
- 新增后台智能体任务监控组件(招标专员/管理员)
- 发起委托增加导入采购计划/直接填写/选择模板(含自动)
- 项目列表:排序/高级搜索/委托人列/提交审批/列显隐
- 项目详情:修正流程步骤、委托人信息、方案与文件合一、提交审批
- 长短名单去分支与推荐部门列、寻源关联项目并支持三来源
- 开标去自动开标、专家管理去抽取与回避、供应商管理去新增与入库
2026-07-10 23:35:54 +08:00
weiwen_ye 38a804ce41 Merge branch 'main' of https://101.132.22.89:3000/Dubhe/STP 2026-07-10 23:03:05 +08:00
weiwen_ye 3a50a3f3ed 更新 招标专员工作台详细设计说明书.md 和 评标专家工作台.md 2026-07-10 23:02:02 +08:00
weiwen_ye 282f1f1b92 添加 前端界面截图内部评审意见-20260710.md 2026-07-10 22:51:06 +08:00
vame 787de5aa88 feat(front-end): 依据《详细设计》文档对前端 UI 进行全面升级
- 设计系统:品牌主色统一为品红渐变 #c2185b→#e91e63,新增品牌令牌与呼吸/入场动画
- 登录页重建:内部员工/外部账号切换、账号/手机验证码 Tab、SRM 存量提示
- 注册页重建为多步向导:企业信息→联系人→AI 审核,OCR 状态、密码复杂度校验与强度条
- 小滨 AI 助手重建:品牌渐变悬浮球+呼吸光环,角色感知主动提醒(紧急/AI完成/待办/风险/建议)
- 新增公开门户首页 /portal:Banner 轮播 + 三列公告区 + 免责声明
- 各角色主题色对齐文档:供应商=红、评标专家=蓝、招标专员=绿

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 23:46:04 +08:00
weiwen_ye f7a49ffb78 修改 招标专员工作台详细设计说明书.md 和 评标专家工作台.md 2026-07-09 18:20:12 +08:00
weiwen_ye 5af4f768f5 docs: 更新需求分析说明书至V1.5
- 删除流程合并待确认标记,该事项已确认
- 投标文件加密描述:由“系统统一加密”改为“系统自动加密”,表述更精确
- 待确定事项汇总:
  - 委托自动合并功能标记为“已确定(暂缓落地)”
  - 投标文件加密方案标记为“已确定”
  - 标书费配置标记为“已确定”,并补充说明“暂时保留接口,详细过程还需商酌”
  - 保证金退还具体流程仍需财务部确定
2026-07-06 17:21:18 +08:00
weiwen_ye f1cd487478 docs: 更新需求分析说明书至V1.5
- 版本号升级至V1.5,日期更新为2026-07-06
- 保证金缴纳流程:改为财务系统API推送至STP,自动确认状态
- 投标文件加密方案:正式确认为系统统一加密(方案②)
- 归档流程:增加“通过OA审批转交”和“本地下载”两种方式,用户可选
- 待确定事项中新增“保证金退还具体流程需财务部确定”
2026-07-06 15:50:06 +08:00
weiwen_ye 3a7a94235a 添加 招标专员工作台详细设计说明书.md 2026-07-06 14:24:32 +08:00
87 changed files with 3975 additions and 1998 deletions
+15
View File
@@ -0,0 +1,15 @@
# 依赖
node_modules/
**/node_modules/
# 构建产物
dist/
**/dist/
.vite/
**/.vite/
# 系统 / 编辑器
.DS_Store
*.log
.idea/
.vscode/
@@ -0,0 +1,618 @@
# 招标专员工作台详细设计说明书
| 文档名称 | 招标专员工作台详细设计 |
| :------- | :---------- |
| **文档版本** | V1.0 |
| **编制日期** | 2026-07-06 |
| **关联文档** | 《需求分析说明书》 |
## 1. 概述
### 1.1 设计目标
招标专员工作台是招标专员登录后默认进入的页面,作为其日常招标业务操作的核心枢纽。设计核心围绕 **“任务驱动”** 与 **“AI主动辅助”** 两大理念:
- **任务驱动**:集中展示待办任务(委托确认、寻源确认、立项审批、评标组织、定标发起等),按招标流程阶段分类,引导专员高效推进。
- **AI主动辅助**:AI助手“小滨”以悬浮窗口形式常驻,主动分析当前待办任务,推送优先级建议、风险预警和操作指引,实现“事找人”的智能工作模式。
### 1.2 适用角色
- 集团及下属公司的招标专员、招标管理部人员。
- 不同专员的待办任务根据其被分配的项目和流程节点差异化展示。
### 1.3 设计原则
| 原则 | 说明 |
| :--- | :--- |
| **流程驱动** | 任务按招标全生命周期(委托→寻源→立项→开标→评标→定标)自然排列,符合业务认知。 |
| **AI优先** | AI助手悬浮窗主动推送待办建议,减少专员主动查找的认知负担。 |
| **效率优先** | 高频操作(确认、审批、发起)一键可达,缩短操作路径。 |
| **实时同步** | 所有任务状态、待办数量实时更新,确保不遗漏关键节点。 |
## 2. 页面布局总览
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 980" font-family="'Microsoft YaHei', sans-serif">
<defs>
<filter id="shadow" x="-2%" y="-2%" width="104%" height="104%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#000" flood-opacity="0.06"/>
</filter>
<filter id="shadowDeep" x="-2%" y="-2%" width="104%" height="104%">
<feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#000" flood-opacity="0.12"/>
</filter>
<linearGradient id="headerBg" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#00695c"/><stop offset="100%" stop-color="#00897b"/>
</linearGradient>
<linearGradient id="brandGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00695c"/><stop offset="100%" stop-color="#00897b"/>
</linearGradient>
<linearGradient id="cardGrad1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e0f2f1"/><stop offset="100%" stop-color="#b2dfdb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="1200" height="980" fill="#f5f7fa"/>
<!-- ===== 顶部导航栏(绿色系,与供应商/专家区分) ===== -->
<rect width="1200" height="64" fill="url(#headerBg)"/>
<text x="40" y="42" font-size="22" font-weight="bold" fill="#fff">智慧招标平台</text>
<text x="190" y="42" font-size="13" fill="rgba(255,255,255,0.8)">Smart Tendering Platform</text>
<!-- 导航菜单 -->
<text x="440" y="42" font-size="15" fill="rgba(255,255,255,0.8)">首页</text>
<text x="520" y="42" font-size="15" fill="#fff" font-weight="bold">工作台</text>
<text x="620" y="42" font-size="15" fill="rgba(255,255,255,0.8)">项目管理</text>
<text x="720" y="42" font-size="15" fill="rgba(255,255,255,0.8)">供应商管理</text>
<text x="820" y="42" font-size="15" fill="rgba(255,255,255,0.8)">专家管理</text>
<!-- 已登录用户 -->
<circle cx="1010" cy="32" r="16" fill="rgba(255,255,255,0.3)"/>
<text x="1010" y="37" text-anchor="middle" font-size="12" fill="#fff"></text>
<text x="1040" y="37" font-size="14" fill="#fff">张婷</text>
<text x="1090" y="37" font-size="12" fill="rgba(255,255,255,0.7)"></text>
<!-- ===== 内容区 ===== -->
<!-- 欢迎横幅 + 今日概览 -->
<rect x="40" y="90" width="1120" height="70" rx="12" fill="#fff" filter="url(#shadow)"/>
<text x="70" y="120" font-size="18" font-weight="bold" fill="#1a2332">👋 早上好,张婷</text>
<text x="70" y="145" font-size="14" fill="#8895aa">今日待办:<tspan fill="#00695c" font-weight="bold">8</tspan> 进行中项目:<tspan fill="#00695c" font-weight="bold">5</tspan></text>
<!-- ============================================================ -->
<!-- ===== 新增:快捷入口(8个图标,2行×4列) ===== -->
<!-- ============================================================ -->
<text x="70" y="200" font-size="16" font-weight="bold" fill="#1a2332">⚡ 快捷入口</text>
<!-- 行1 -->
<!-- 入口1:新建委托 -->
<rect x="40" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="40" y="220" width="130" height="4" rx="2" fill="#e91e63"/>
<text x="105" y="252" text-anchor="middle" font-size="24">📝</text>
<text x="105" y="280" text-anchor="middle" font-size="13" fill="#4a5568">新建委托</text>
<!-- 入口2:我的项目 -->
<rect x="185" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="185" y="220" width="130" height="4" rx="2" fill="#1565c0"/>
<text x="250" y="252" text-anchor="middle" font-size="24">📂</text>
<text x="250" y="280" text-anchor="middle" font-size="13" fill="#4a5568">我的项目</text>
<!-- 入口3:供应商库 -->
<rect x="330" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="330" y="220" width="130" height="4" rx="2" fill="#ff9800"/>
<text x="395" y="252" text-anchor="middle" font-size="24">🏢</text>
<text x="395" y="280" text-anchor="middle" font-size="13" fill="#4a5568">供应商库</text>
<!-- 入口4:专家库 -->
<rect x="475" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="475" y="220" width="130" height="4" rx="2" fill="#1a237e"/>
<text x="540" y="252" text-anchor="middle" font-size="24">👨‍🏫</text>
<text x="540" y="280" text-anchor="middle" font-size="13" fill="#4a5568">专家库</text>
<!-- 入口5:模板库 -->
<rect x="620" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="620" y="220" width="130" height="4" rx="2" fill="#4caf50"/>
<text x="685" y="252" text-anchor="middle" font-size="24">📄</text>
<text x="685" y="280" text-anchor="middle" font-size="13" fill="#4a5568">模板库</text>
<!-- 入口6:报表中心 -->
<rect x="765" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="765" y="220" width="130" height="4" rx="2" fill="#e65100"/>
<text x="830" y="252" text-anchor="middle" font-size="24">📊</text>
<text x="830" y="280" text-anchor="middle" font-size="13" fill="#4a5568">报表中心</text>
<!-- 入口7:保证金管理 -->
<rect x="910" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="910" y="220" width="130" height="4" rx="2" fill="#9c27b0"/>
<text x="975" y="252" text-anchor="middle" font-size="24">💰</text>
<text x="975" y="280" text-anchor="middle" font-size="13" fill="#4a5568">保证金管理</text>
<!-- 入口8:定标审批 -->
<rect x="1055" y="220" width="130" height="80" rx="10" fill="#fff" filter="url(#shadow)"/>
<rect x="1055" y="220" width="130" height="4" rx="2" fill="#c62828"/>
<text x="1120" y="252" text-anchor="middle" font-size="24"></text>
<text x="1120" y="280" text-anchor="middle" font-size="13" fill="#4a5568">定标审批</text>
<!-- 行2:新增推荐入口(AI智能推荐,带小滨角标) -->
<text x="70" y="330" font-size="14" font-weight="bold" fill="#00695c">🤖 小滨推荐</text>
<!-- 推荐1AI寻源 -->
<rect x="40" y="350" width="200" height="60" rx="10" fill="#e8f5e9" stroke="#4caf50" filter="url(#shadow)"/>
<text x="70" y="378" font-size="20">🔍</text>
<text x="100" y="375" font-size="14" font-weight="bold" fill="#2e7d32">AI智慧寻源</text>
<text x="100" y="396" font-size="12" fill="#4a5568">2个项目待AI寻源</text>
<rect x="190" y="355" width="30" height="16" rx="8" fill="#ff9800"/>
<text x="205" y="367" text-anchor="middle" font-size="10" fill="#fff">2</text>
<!-- 推荐2:待定标 -->
<rect x="260" y="350" width="200" height="60" rx="10" fill="#fff3e0" stroke="#ffcc80" filter="url(#shadow)"/>
<text x="290" y="378" font-size="20">📋</text>
<text x="320" y="375" font-size="14" font-weight="bold" fill="#e65100">待定标项目</text>
<text x="320" y="396" font-size="12" fill="#4a5568">1个项目待发起定标</text>
<rect x="410" y="355" width="30" height="16" rx="8" fill="#f44336"/>
<text x="425" y="367" text-anchor="middle" font-size="10" fill="#fff">1</text>
<!-- ===== 待办任务看板(4个阶段卡片) ===== -->
<text x="70" y="460" font-size="16" font-weight="bold" fill="#1a2332">📋 待办任务</text>
<!-- 卡片1:待处理委托 -->
<rect x="40" y="480" width="260" height="170" rx="12" fill="#fff" filter="url(#shadow)"/>
<rect x="40" y="480" width="260" height="6" rx="3" fill="#e91e63"/>
<text x="60" y="508" font-size="14" font-weight="bold" fill="#e91e63">📝 待确认委托</text>
<text x="60" y="534" font-size="32" font-weight="bold" fill="#1a2332">3</text>
<text x="60" y="554" font-size="13" fill="#8895aa">个委托单待审核确认</text>
<text x="60" y="580" font-size="13" fill="#e91e63" text-decoration="underline">查看全部 →</text>
<rect x="60" y="600" width="80" height="24" rx="12" fill="#ffebee"/>
<text x="100" y="617" text-anchor="middle" font-size="11" fill="#c62828">新委托</text>
<!-- 卡片2:待寻源确认 -->
<rect x="320" y="480" width="260" height="170" rx="12" fill="#fff" filter="url(#shadow)"/>
<rect x="320" y="480" width="260" height="6" rx="3" fill="#ff9800"/>
<text x="340" y="508" font-size="14" font-weight="bold" fill="#e65100">🔍 待寻源确认</text>
<text x="340" y="534" font-size="32" font-weight="bold" fill="#1a2332">2</text>
<text x="340" y="554" font-size="13" fill="#8895aa">个寻源结果待确认</text>
<text x="340" y="580" font-size="13" fill="#e65100" text-decoration="underline">查看全部 →</text>
<rect x="340" y="600" width="80" height="24" rx="12" fill="#fff3e0"/>
<text x="380" y="617" text-anchor="middle" font-size="11" fill="#e65100">AI已完成</text>
<!-- 卡片3:待立项审批 -->
<rect x="600" y="480" width="260" height="170" rx="12" fill="#fff" filter="url(#shadow)"/>
<rect x="600" y="480" width="260" height="6" rx="3" fill="#1565c0"/>
<text x="620" y="508" font-size="14" font-weight="bold" fill="#1565c0">📄 待立项审批</text>
<text x="620" y="534" font-size="32" font-weight="bold" fill="#1a2332">1</text>
<text x="620" y="554" font-size="13" fill="#8895aa">个招标方案待审批</text>
<text x="620" y="580" font-size="13" fill="#1565c0" text-decoration="underline">查看全部 →</text>
<rect x="620" y="600" width="80" height="24" rx="12" fill="#e3f2fd"/>
<text x="660" y="617" text-anchor="middle" font-size="11" fill="#0d47a1">待发起</text>
<!-- 卡片4:待开标/评标 -->
<rect x="880" y="480" width="260" height="170" rx="12" fill="#fff" filter="url(#shadow)"/>
<rect x="880" y="480" width="260" height="6" rx="3" fill="#4caf50"/>
<text x="900" y="508" font-size="14" font-weight="bold" fill="#2e7d32">⚖️ 待开标/评标</text>
<text x="900" y="534" font-size="32" font-weight="bold" fill="#1a2332">2</text>
<text x="900" y="554" font-size="13" fill="#8895aa">个项目待组织评标</text>
<text x="900" y="580" font-size="13" fill="#2e7d32" text-decoration="underline">查看全部 →</text>
<rect x="900" y="600" width="80" height="24" rx="12" fill="#e8f5e9"/>
<text x="940" y="617" text-anchor="middle" font-size="11" fill="#2e7d32">即将截止</text>
<!-- ===== 我的在办项目(表格) ===== -->
<text x="70" y="700" font-size="16" font-weight="bold" fill="#1a2332">📂 我的在办项目</text>
<rect x="40" y="720" width="1120" height="180" rx="12" fill="#fff" filter="url(#shadow)"/>
<!-- 表头 -->
<rect x="40" y="720" width="1120" height="36" rx="12" fill="#f0f2f7"/>
<text x="70" y="744" font-size="13" font-weight="bold" fill="#4a5568">项目编号</text>
<text x="200" y="744" font-size="13" font-weight="bold" fill="#4a5568">项目名称</text>
<text x="420" y="744" font-size="13" font-weight="bold" fill="#4a5568">当前阶段</text>
<text x="560" y="744" font-size="13" font-weight="bold" fill="#4a5568">所属委托</text>
<text x="700" y="744" font-size="13" font-weight="bold" fill="#4a5568">截止日期</text>
<text x="840" y="744" font-size="13" font-weight="bold" fill="#4a5568">状态</text>
<text x="980" y="744" font-size="13" font-weight="bold" fill="#4a5568">操作</text>
<!-- 行1 -->
<rect x="50" y="765" width="1100" height="36" rx="4" fill="#fff"/>
<text x="70" y="788" font-size="13" fill="#1a2332">BID-2026-001</text>
<text x="200" y="788" font-size="13" fill="#1a2332">年产5万吨聚丙烯项目设备采购</text>
<rect x="420" y="776" width="60" height="18" rx="4" fill="#ff9800"/>
<text x="450" y="789" text-anchor="middle" font-size="11" fill="#fff">寻源中</text>
<text x="560" y="788" font-size="13" fill="#666">技术部-李工</text>
<text x="700" y="788" font-size="13" fill="#666">2026-07-15</text>
<rect x="840" y="776" width="60" height="18" rx="4" fill="#4caf50"/>
<text x="870" y="789" text-anchor="middle" font-size="11" fill="#fff">进行中</text>
<text x="980" y="788" font-size="13" fill="#00695c" text-decoration="underline">继续</text>
<!-- 行2 -->
<rect x="50" y="805" width="1100" height="36" rx="4" fill="#fff"/>
<text x="70" y="828" font-size="13" fill="#1a2332">BID-2026-003</text>
<text x="200" y="828" font-size="13" fill="#1a2332">数字化交付平台开发服务</text>
<rect x="420" y="816" width="60" height="18" rx="4" fill="#1565c0"/>
<text x="450" y="829" text-anchor="middle" font-size="11" fill="#fff">立项中</text>
<text x="560" y="828" font-size="13" fill="#666">数字化部-王工</text>
<text x="700" y="828" font-size="13" fill="#666">2026-07-10</text>
<rect x="840" y="816" width="60" height="18" rx="4" fill="#ff9800"/>
<text x="870" y="829" text-anchor="middle" font-size="11" fill="#fff">即将截止</text>
<text x="980" y="828" font-size="13" fill="#00695c" text-decoration="underline">处理</text>
<!-- 行3 -->
<rect x="50" y="845" width="1100" height="36" rx="4" fill="#fff"/>
<text x="70" y="868" font-size="13" fill="#1a2332">BID-2026-005</text>
<text x="200" y="868" font-size="13" fill="#1a2332">2026年度防腐保温工程框架</text>
<rect x="420" y="856" width="60" height="18" rx="4" fill="#e91e63"/>
<text x="450" y="869" text-anchor="middle" font-size="11" fill="#fff">待开标</text>
<text x="560" y="868" font-size="13" fill="#666">工程部-刘工</text>
<text x="700" y="868" font-size="13" fill="#666">2026-07-08</text>
<rect x="840" y="856" width="60" height="18" rx="4" fill="#f44336"/>
<text x="870" y="869" text-anchor="middle" font-size="11" fill="#fff">紧急</text>
<text x="980" y="868" font-size="13" fill="#00695c" text-decoration="underline">组织评标</text>
<!-- 查看更多 -->
<text x="1020" y="890" font-size="13" fill="#00695c" text-decoration="underline">查看全部项目 →</text>
<!-- ============================================================ -->
<!-- ===== AI 助手“小滨”悬浮窗 ===== -->
<!-- ============================================================ -->
<g transform="translate(800, 480)">
<rect x="0" y="0" width="340" height="400" rx="16" fill="rgba(0,0,0,0.15)" filter="url(#shadowDeep)"/>
<rect x="0" y="0" width="340" height="400" rx="16" fill="#ffffff" filter="url(#shadowDeep)"/>
<!-- 头部 -->
<rect x="0" y="0" width="340" height="52" rx="16" fill="url(#headerBg)"/>
<rect x="0" y="28" width="340" height="24" fill="url(#headerBg)"/>
<circle cx="24" cy="26" r="10" fill="rgba(255,255,255,0.3)"/>
<text x="24" y="30" text-anchor="middle" font-size="10" fill="#fff" font-weight="bold"></text>
<text x="42" y="30" font-size="15" font-weight="bold" fill="#fff">小滨 AI 助手</text>
<text x="310" y="24" font-size="16" fill="rgba(255,255,255,0.7)"></text>
<text x="325" y="24" font-size="16" fill="rgba(255,255,255,0.7)"></text>
<!-- 状态栏 -->
<rect x="16" y="62" width="308" height="28" rx="6" fill="#e8f5e9"/>
<circle cx="32" cy="76" r="5" fill="#4caf50"/>
<text x="44" y="81" font-size="12" fill="#2e7d32">● 在线 · 已为您分析今日待办</text>
<!-- 消息1 -->
<rect x="16" y="100" width="308" height="64" rx="8" fill="#fff3e0" stroke="#ffcc80"/>
<text x="28" y="122" font-size="13" font-weight="bold" fill="#e65100">🔥 紧急:BID-2026-005 今日需开标</text>
<text x="28" y="144" font-size="12" fill="#bf360c">“2026年度防腐保温工程框架”已到开标时间,请立即组织评标</text>
<text x="290" y="144" font-size="12" fill="#e65100" font-weight="bold" text-decoration="underline">去处理 →</text>
<!-- 消息2 -->
<rect x="16" y="174" width="308" height="64" rx="8" fill="#e8f5e9" stroke="#a5d6a7"/>
<text x="28" y="196" font-size="13" font-weight="bold" fill="#2e7d32">✅ AI 已完成:寻源报告生成</text>
<text x="28" y="218" font-size="12" fill="#1b5e20">“年产5万吨聚丙烯项目”已自动寻源,匹配到12家潜在供应商</text>
<text x="290" y="218" font-size="12" fill="#2e7d32" font-weight="bold" text-decoration="underline">查看 →</text>
<!-- 消息3 -->
<rect x="16" y="248" width="308" height="64" rx="8" fill="#e3f2fd" stroke="#90caf9"/>
<text x="28" y="270" font-size="13" font-weight="bold" fill="#0d47a1">📋 您有 3 个委托单待审核</text>
<text x="28" y="292" font-size="12" fill="#0d47a1">来自技术部、采购部的委托申请已提交,请及时审核确认</text>
<text x="290" y="292" font-size="12" fill="#0d47a1" font-weight="bold" text-decoration="underline">去审核 →</text>
<!-- 输入框 -->
<rect x="16" y="340" width="240" height="40" rx="20" fill="#f5f7fa" stroke="#e0e4ec"/>
<text x="30" y="365" font-size="13" fill="#b0bbcc">输入您的问题...</text>
<rect x="268" y="340" width="56" height="40" rx="20" fill="url(#brandGrad)"/>
<text x="296" y="366" text-anchor="middle" font-size="18" fill="#fff"></text>
</g>
<!-- 小窗边缘提示角标 -->
<circle cx="1140" y="490" r="10" fill="#f44336"/>
<text x="1140" y="495" text-anchor="middle" font-size="10" fill="#fff" font-weight="bold">5</text>
</svg>
```
## 3. 各模块详细说明
### 3.1 顶部导航栏
- **品牌区**:平台Logo + 名称,点击返回首页。
- **导航菜单**:首页、工作台(高亮)、项目管理、供应商管理、专家管理。
- **用户区**:显示招标专员头像(首字母)、姓名,下拉菜单含个人设置、退出等。
- **颜色主题**:绿色系(#00695c#00897b),与供应商(红色)、专家(蓝紫色)区分。
### 3.2 快捷入口模块
> 位于欢迎横幅与待办看板之间,提供高频功能的一键直达入口。
#### 3.2.1 布局与尺寸
| 属性 | 说明 |
| -------- | ------------------------------------- |
| **位置** | 欢迎横幅下方,待办看板上方 |
| **布局** | 两行:第一行为 8 个固定入口(2行×4列),第二行为“小滨推荐”动态入口 |
| **入口尺寸** | 宽 130px,高 80px,间距 15px |
| **背景** | 白色卡片,圆角 10px,带浅阴影 |
| **顶部色条** | 每个入口顶部有 4px 高的彩色条,用于视觉区分 |
#### 3.1.2 固定快捷入口列表
|序号|图标|名称|顶部色条|跳转目标|说明|
|---|---|---|---|---|---|
|1|📝|新建委托|红色(#e91e63)|委托创建页|快速发起新的招标委托|
|2|📂|我的项目|蓝色(#1565c0)|项目管理列表|查看所有负责的项目|
|3|🏢|供应商库|橙色(#ff9800)|供应商管理页|查询/管理供应商信息|
|4|👨‍🏫|专家库|深蓝(#1a237e|专家管理页|查询/管理评审专家|
|5|📄|模板库|绿色(#4caf50|模板管理页|招标文件/评分模板|
|6|📊|报表中心|深橙(#e65100|报表统计页|招标数据统计分析|
|7|💰|保证金管理|紫色(#9c27b0)|保证金管理页|保证金缴纳/退还管理|
|8|✅|定标审批|深红(#c62828|定标审批列表|快速发起定标审批|
### 3.3 待办任务看板(4个阶段卡片)
> 按招标全生命周期阶段组织,每个卡片突出显示待办数量,支持点击查看明细。
| 卡片 | 颜色主题 | 数据来源 | 任务类型 |
| :--- | :--- | :--- | :--- |
| **待确认委托** | 红色(#e91e63) | 委托流程 | 委托审核确认、委托补充 |
| **待寻源确认** | 橙色(#ff9800) | 供应商寻源流程 | AI寻源结果确认、短名单确认 |
| **待立项审批** | 蓝色(#1565c0) | 招标立项流程 | 招标方案评审、审批发起 |
| **待开标/评标** | 绿色(#4caf50) | 开标及评标流程 | 截标确认、专家抽取、组织评标 |
**交互规则**
- 点击卡片任意区域 → 跳转至对应任务列表页。
- 点击“查看全部” → 跳转至带筛选条件的完整任务列表。
- 右上角标签 → 显示该阶段最高优先级状态(如“新委托”、“即将截止”)。
- 数字实时更新,有变化时数字闪烁(0.5s 呼吸动画)。
### 3.4 我的在办项目
| 属性 | 说明 |
| :--- | :--- |
| **位置** | 待办看板下方 |
| **尺寸** | 1120 × 200px |
| **展示逻辑** | 当前专员负责的、状态为“进行中”的项目,按紧急程度排序(即将截止优先) |
| **表格列** | 项目编号、项目名称、当前阶段、所属委托、截止日期、状态、操作 |
| **状态标签** | 进行中(绿色)、即将截止(橙色)、紧急(红色) |
| **操作列** | 根据项目阶段动态变化:继续、处理、组织评标、查看等 |
| **查看更多** | 跳转至完整项目列表页 |
**操作列映射表**
| 项目阶段 | 操作按钮 | 跳转目标 |
| :--- | :--- | :--- |
| 委托中 | 确认 | 委托详情页 |
| 寻源中 | 查看报告 | AI寻源结果页 |
| 立项中 | 编辑方案 | 招标方案编辑页 |
| 待开标 | 组织评标 | 开标准备页 |
| 评标中 | 进入评标 | 评标管理页 |
| 定标中 | 发起审批 | 定标审批页 |
## 3.5 后台智能体任务监控
### 3.5.1 模块定位
| 属性 | 说明 |
| -------- | ------------------------------------------------------------ |
| **模块名称** | 后台智能体任务(AI Agent Task Monitor |
| **位置** | 待办任务看板下方,在办项目列表上方 |
| **功能** | 实时展示后台异步 AI 智能体任务的执行进度,包括任务名称、关联项目、执行状态、进度百分比、关键中间结果、预计剩余时间等 |
| **刷新机制** | 每 3 秒轮询 / WebSocket 推送(推荐) |
### 3.5.2 任务类型清单
|任务图标|任务名称|触发时机|典型耗时|输出产物|
|---|---|---|---|---|
|🔍|AI 智慧寻源|委托确认后自动触发|2-10 分钟|潜在供应商名单 + 推荐报告|
|🔬|AI 雷同检测|投标文件上传完成后|1-8 分钟|雷同检测报告|
|📊|AI 价格分析|开标完成后|1-3 分钟|价格偏离度分析报告|
|👨‍🏫|AI 专家推荐|评标前准备阶段|30 秒-2 分钟|匹配专家列表|
|📄|AI 评标报告生成|评标打分完成后|1-2 分钟|评标报告初稿|
|🧩|AI 合并招标建议|委托生效时|1-3 分钟|合并招标建议书|
|📋|AI 资格初审|供应商报名后|1-5 分钟|资格预审结果|
### 3.5.3 任务状态与视觉规范
|状态|色条颜色|状态标签|进度条样式|说明|
|---|---|---|---|---|
|**待执行**|灰色(#9e9e9e)|⏸ 待执行|灰色空进度条|任务在队列中等待|
|**执行中**|橙色(#ff9800)|⏳ 执行中|橙色渐变进度条 + 百分比|正在运行,显示进度|
|**已完成**|绿色(#4caf50)|✅ 已完成|绿色实心进度条|任务成功完成|
|**失败**|红色(#f44336)|❌ 执行失败|红色进度条 + 错误标记|任务异常终止|
### 3.5.4 单条任务卡片结构
|区域|元素|说明|
|---|---|---|
|**左侧**|色条|6px 宽,颜色对应任务状态|
|**图标区**|任务图标 + 圆形背景|28px 圆,背景色与状态匹配|
|**任务信息**|任务名称(加粗)+ 关联项目名称|显示任务所属项目|
|**状态区**|状态标签 + 进度条 + 百分比|展示当前执行进度|
|**详情区**|关键中间结果|如“已匹配 8/20 家供应商”|
|**时间区**|预计剩余时间 / 完成时间|执行中显示剩余,已完成显示完成时间|
|**操作区**|取消 / 查看报告|执行中可取消,已完成可查看报告|
### 3.5.5 任务卡片示例数据
|任务|状态|进度|中间结果|预计剩余|操作|
|---|---|---|---|---|---|
|AI 智慧寻源|执行中|70%|已匹配:8 家供应商|2 分钟|取消|
|AI 雷同检测|执行中|30%|已扫描:3/10 份|5 分钟|取消|
|AI 价格分析|已完成|100%|发现 1 份报价偏离 >20%|09:35 完成|查看报告|
|AI 专家推荐|待执行|0%|—|—|—|
### 3.5.6 点击交互
|交互|说明|
|---|---|
|**点击任务卡片**|跳转至任务详情页(展示完整的执行日志和中间结果)|
|**点击“取消”**|弹出二次确认弹窗,确认后终止任务(仅限执行中状态)|
|**点击“查看报告”**|在新窗口打开或下载生成的报告文件|
|**悬停进度条**|显示详细进度信息(Tooltip|
### 3.5.7 智能体任务与 AI 助手的协同
当智能体任务状态发生变化时,AI助手“小滨”会同步推送主动提醒:
|任务状态变化|小滨推送内容|示例|
|---|---|---|
|任务完成|“✅ AI [任务名称] 已完成,请查看报告”|“✅ AI 价格分析已完成,发现 1 份报价偏离度 >20%”|
|任务失败|“❌ AI [任务名称] 执行失败,请重试或联系管理员”|“❌ AI 雷同检测执行失败,请检查投标文件格式”|
|关键节点|“📌 AI [任务名称] 已找到 [数量] 个潜在供应商”|“📌 AI 智慧寻源已找到 12 家潜在供应商”|
### 3.6 AI 助手“小滨”悬浮窗(核心特色)
#### 3.6.1 视觉设计
| 属性 | 说明 |
| :--- | :--- |
| **位置** | `position: fixed`,默认位于右下角(距底部80px,距右侧40px) |
| **尺寸** | 宽340px,高400px |
| **可拖动** | 用户可通过拖拽头部区域移动悬浮窗位置 |
| **可折叠** | 点击头部“−”按钮最小化到右下角图标,点击“✕”关闭(可在系统设置中重新开启) |
#### 3.6.2 主动提示能力
AI助手“小滨”**不需要用户主动提问**,而是主动分析当前专员的待办任务状态,推送以下类型提示:
| 提示类型 | 触发条件 | 样式 | 示例 |
| :----------- | :------------ | :------------ | :------------------- |
| **🔥 紧急提醒** | 任务截止时间 < 24小时 | 橙色背景(#fff3e0 | “BID-2026-005 今日需开标” |
| **✅ AI完成通知** | AI自动完成阶段性工作 | 绿色背景(#e8f5e9) | “✅ AI已完成:寻源报告生成” |
| **📋 待办汇总** | 日常任务堆积提醒 | 蓝色背景(#e3f2fd) | “您有3个委托单待审核” |
| **⚠️ 风险预警** | 检测到异常或风险 | 红色背景(#ffebee) | “⚠️ 供应商不足3家,需二次寻源” |
| **💡 操作建议** | 下一步最佳操作 | 灰色背景(#f5f5f5) | “💡 建议优先处理即将截止的委托” |
提示示例:
您当前有3项待确认委托,一个立项待审批,防腐保温工程最紧急,点击*这里*(链接)进行处理。
#### 3.5.3 消息推送规则
```mermaid
graph TD
A[定时扫描待办任务<br>(每30秒)] --> B{检测到待办?}
B -->|无| C[显示“暂无待办,继续保持!”]
B -->|有| D[按优先级排序]
D --> E{优先级判定}
E -->|截止时间<24h| F[生成“紧急提醒”]
E -->|AI自动完成| G[生成“AI完成通知”]
E -->|常规任务| H[生成“待办汇总”]
E -->|异常检测| I[生成“风险预警”]
F --> J[按时间倒序展示在悬浮窗]
G --> J
H --> J
I --> J
J --> K[最多显示4条消息]
K --> L[用户点击“去处理”→跳转对应页面]
```
#### 3.5.4 消息交互
| 交互 | 说明 |
| :--- | :--- |
| **点击消息卡片** | 跳转至对应的任务处理页面(如点击“委托审核”→委托审核列表) |
| **点击“去处理”** | 同点击消息卡片,跳转至对应页面 |
| **输入框提问** | 用户可在底部输入框主动提问,小滨基于知识库+当前任务上下文回答 |
| **消息自动消失** | 任务处理后,该消息在下次刷新时自动消失 |
| **消息手动关闭** | 点击消息右上角“✕”可手动关闭该条消息 |
#### 3.5.5 状态指示器
- 在线状态:绿色圆点 + “在线 · 已为您分析今日待办”
- 处理中状态(AI执行任务时):橙色圆点 + “正在分析任务...”
- 离线/错误状态:红色圆点 + “连接异常,点击重试”
## 4. 交互流程
### 4.1 待办任务处理完整流程
```mermaid
flowchart TD
A[专员登录工作台] --> B[小滨自动扫描待办]
B --> C[待办卡片显示数量]
B --> D[小滨窗口推送优先级消息]
D --> E{专员操作}
E -->|点击消息卡片| F[跳转至对应任务页]
E -->|点击待办卡片| F
E -->|点击项目操作按钮| G[跳转至具体操作页]
F --> H[完成任务操作]
G --> H
H --> I[返回工作台]
I --> J[小滨自动刷新待办状态]
J --> K{有待办?}
K -->|有| L[更新待办数量+消息列表]
K -->|无| M[显示“🎉 所有任务已处理完毕!”]
```
### 4.2 小滨主动提醒触发场景
| 场景 | 触发时机 | 小滨动作 |
| :--- | :--- | :--- |
| 新委托到达 | 委托审核通过 | 推送“📋 新的委托单已到达,请确认” |
| AI寻源完成 | 寻源任务完成 | 推送“✅ AI已完成供应商寻源,请确认短名单” |
| 开标时间临近 | 开标前24小时 | 推送“🔥 项目XX即将开标,请确认准备工作” |
| 有效投标人不足 | 报名截止后 | 推送“⚠️ 有效投标人不足3家,建议二次寻源” |
| 定标待审批 | 评标完成 | 推送“📋 评标已完成,请发起定标审批” |
| 无待办任务 | 所有任务完成 | 推送“🎉 今日所有任务已处理完毕!” |
### 4.3 项目列表操作交互
- 点击项目行任意位置 → 跳转至项目详情页。
- 点击操作列按钮 → 跳转至具体任务处理页(如“组织评标”→评标准备页)。
- 支持行内快速操作(如状态变更下拉)。
## 5. 数据模型
### 5.1 待办统计 API
```
GET /api/v1/buyer/todo/statistics
响应:
{
"pendingEntrust": 3, // 待确认委托
"pendingSourcing": 2, // 待寻源确认
"pendingApproval": 1, // 待立项审批
"pendingBidOpen": 2, // 待开标/评标
"todayTotal": 8 // 今日待办总计
}
```
### 5.2 在办项目列表
```
GET /api/v1/buyer/projects/ongoing
参数:limit=5
响应:[{
"projectId": "BID-2026-001",
"projectName": "年产5万吨聚丙烯项目设备采购",
"phase": "SOURCING", // ENTRUST/SOURCING/APPROVAL/BID_OPEN/EVALUATION/FINALIZE
"entrustDept": "技术部-李工",
"deadline": "2026-07-15",
"status": "ONGOING", // ONGOING/URGENT/EXPIRING
"action": "VIEW_REPORT" // CONFIRM/EDIT/ORGANIZE/VIEW等
}]
```
### 5.3 小滨消息列表(由后端实时生成)
```
GET /api/v1/buyer/ai-messages
响应:[{
"id": "MSG_001",
"type": "URGENT", // URGENT/AI_COMPLETE/TODO/RISK/SUGGESTION
"title": "BID-2026-005 今日需开标",
"content": "“2026年度防腐保温工程框架”已到开标时间,请立即组织评标",
"action": "ORGANIZE_BID", // 对应跳转路由
"actionLabel": "去处理",
"createTime": "2026-07-06T09:00:00Z",
"expireTime": "2026-07-07T09:00:00Z"
}]
```
## 6. 业务规则
| ID | 规则 | 说明 |
| :--- | :--- | :--- |
| R01 | 待办看板按流程阶段分组,每个阶段独立计数 | 委托/寻源/立项/开标评标 |
| R02 | 小滨消息最多展示4条,按优先级排序 | 紧急>风险>AI完成>待办汇总 |
| R03 | 消息有效期24小时,超时自动归档 | 避免信息过载 |
| R04 | 任务处理后,相关消息在下次刷新时自动消失 | 实时同步 |
| R05 | 待办卡片数字变化时触发呼吸动画 | 吸引注意 |
| R06 | 小滨悬浮窗默认展开,支持最小化 | 用户可控制 |
## 7. 安全与性能
| 维度 | 措施 |
| :--- | :--- |
| **权限控制** | 仅招标专员可见工作台,数据按专员权限隔离 |
| **数据实时性** | 待办统计每30秒轮询更新,WebSocket推送状态变更 |
| **消息历史** | AI助手消息保留7天,可查询历史记录 |
| **防打扰** | 支持关闭小滨主动推送(仅保留角标提醒) |
@@ -79,7 +79,7 @@
<rect x="40" y="90" width="780" height="830" rx="12" fill="#fff" filter="url(#shadow)"/>
<!-- 欢迎语 + 专家角色标签 -->
<text x="70" y="125" font-size="20" font-weight="bold" fill="#1a2332">👋 欢迎回来,王建国 教授</text>
<text x="70" y="125" font-size="20" font-weight="bold" fill="#1a2332">👋 欢迎回来,王建国</text>
<rect x="280" y="108" width="100" height="24" rx="12" fill="#e3f2fd" stroke="#1565c0"/>
<text x="330" y="125" text-anchor="middle" font-size="12" fill="#0d47a1">外部专家</text>
<text x="70" y="150" font-size="14" fill="#8895aa">评审专业:化学工程 石油化工</text>
@@ -277,7 +277,7 @@
```
## 3. 各模块详细说明(文字描述)
## 3. 各模块详细说明
### 3.1 顶部导航栏
| 元素 | 说明 |
@@ -288,12 +288,12 @@
| **配色** | 采用蓝色系(#0d47a1#1976d2),与供应商的红色系形成角色区分。 |
### 3.2 欢迎区
| 属性 | 说明 |
| :--- | :--- |
| **内容** | 欢迎语 + 专家姓名 + 职称(如“王建国 教授”) |
| **角色标签** | “外部专家”或“内部专家”标识,圆角标签样式 |
| 属性 | 说明 |
| :------- | :-------------------------- |
| **内容** | 欢迎语 + 专家姓名 + 职称(如“王建国 教授”) |
| **角色标签** | “外部专家”或“内部专家”标识,圆角标签样式 |
| **专业信息** | 显示专家的核心专业领域(如“化学工程 | 石油化工”) |
| **数据来源** | 从专家档案中读取 |
| **数据来源** | 从专家档案中读取 |
### 3.3 待办事项卡片
| 属性 | 说明 |
@@ -322,7 +322,7 @@
| **展示信息** | 项目名称、评审完成日期、状态(已提交) |
| **交互** | 点击记录跳转至历史评审详情页(只读模式) |
### 3.6 右侧边栏(删除快捷操作,调整顺序)
### 3.6 右侧边栏
#### 3.6.1 专家信息概览
| 属性 | 说明 |
+15 -12
View File
@@ -1,7 +1,7 @@
# 滨化集团智慧招标平台(STP)用户需求分析说明书
**版本:** V1.4
**编制日期:** 2026年6月25
**版本:** V1.5
**编制日期:** 2026年7月6
**编制依据:**
- 《招标管理制度》(2026年4月版)
- 《招标评委管理规定》(2026年4月版)
@@ -259,7 +259,6 @@ graph TD
```
**==流程合并待确认==**
#### 关键表单
- **《供应商长名单》、《供应商短名单》、《报名通过单位名单》**(来自8.4.4.1流程)
- **《评标委员会》**BH-SCM-JL-011):在立项阶段初步确定工作领导小组。
@@ -311,8 +310,8 @@ graph TD
| **输出** | 审核通过的供应商名单、投标文件存证。 |
| **角色操作** | - **供应商**:注册、报名、缴费、上传标书。<br>- **AI**:自动审核资质、验真、对账。<br>- **相关部门人员**:对报名单位业绩等进行评审。 |
| **标书费收取** | ==是否收取标书费以及标书费的金额在招标方案制定确定。<br>供应商缴纳标书费后向平台提交转账凭证,平台发起审批向财务确认,确认后才能查看招标文件内容。== |
| **缴纳保证金** | ==供应商缴纳保证金后,向平台提交保证金转账证据,平台发起审批向财务确认,确认后自动转为保证金缴纳状态。== |
| **业务规则** | - 投标保证金≤预算2%,最高80万元(仅提醒)(制度5.9.3.1)。<br>- 投标文件必须加密(**==系统加密/个人加密,待确认==**),开标前不可解密。 |
| **缴纳保证金** | ==供应商缴纳保证金后,向平台提交保证金转账证据,财务系统API-推送至STP,确认后自动转为保证金缴纳状态。== |
| **业务规则** | - 投标保证金≤预算2%,最高80万元(仅提醒)(制度5.9.3.1)。<br>- 投标文件必须加密(**==系统自动加密==**),开标前不可解密。 |
| **异常处理** | - 缴费未到账:系统轮询支付网关 |
| **AI赋能** | - **OCR识别**:提取资质证书关键字段。<br>- **真伪核验**:对接工商/税务API验真。<br>- **自动对账**:支付回调后更新缴费状态。<br>- **区块链存证**:存证投标文件哈希。 |
@@ -463,8 +462,10 @@ graph TD
A[合同签订完成] --> B[系统自动收集全流程资料]
B --> C[按制度5.16清单整理]
C --> D[生成电子归档文件包]
D --> E[推送至电子档案系统]
E --> F[归档完成]
D --> E1[通过OA审批转交]
D -.->|同步| E2[提供下载]
E1 --> F[归档完成]
E2 -.->F
F --> G[审计人员随时查阅]
G --> H[BI看板实时展示]
```
@@ -480,7 +481,7 @@ graph TD
| **输出** | 归档文件包、审计日志。 |
| **角色操作** | - **系统**:自动收集、整理、推送。<br>- **审计/合规**:查询、导出。 |
| **业务规则** | - 归档资料必须包括制度5.16列明的所有文件。 |
| **归档系统** | 紫光档案管理系统 |
| **归档方式** | ==在stp上面打包成压缩包,全流程快照。<br>人工下载之后保存留档 & 通过OA转交给他人<br>(两个功能都要有,用户任选)== |
| **异常处理** | - 资料缺失:系统提示缺失项,要求补传。 |
| **AI赋能** | - **智能分类**:自动分类并按规范命名。<br>- **缺失检测**:对比归档清单,自动识别缺失文件。<br>- **审计辅助**:支持自然语言查询(如“查询2025年所有围标疑似项目”)。 |
@@ -752,12 +753,14 @@ Agent 在调用任何业务数据前,必须通过统一数据网关验证请
## 第六章 待确定事项汇总
* **委托自动合并功能**(同类型多立项合并)暂缓落地,根据业务使用频次确定是否开发。
* **投标文件加密方案**
* **委托自动合并功能(已确定)**(同类型多立项合并)**暂缓落地**,根据业务使用频次确定是否开发。
* **投标文件加密方案(已确定)**
两种方案待定:
①供应商自主设密钥,开标现场输密码解密(弊端:联系人失联无法开标);
②系统统一加密、平台自动解密(弊端供应商不信任数据安全)。
经商定,以方案②,系统统一加密的方式进行。
* **标书费配置**
**经商定,以方案②,系统统一加密的方式进行。**
* **标书费配置(已确定)**
1. 收费节点定于标书发放前,付费后解锁招标文件查看权限;
2. 招标方案制定时可选择是否收取标书费;
3. 暂时保留接口,详细过程还需要商酌。
- **保证金退还具体流程** ==还需要财务部确定==。
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

@@ -0,0 +1,200 @@
# 前端界面截图内部评审意见-20260710
## 1. 主题颜色还是统一配科技蓝吧,跟滨化的LOGO比较搭
## 2. 提醒事项直接让 AI 助手对话窗口显示,用户进入工作台默认就会看到助手的消息,不用点开,但可以收起来。
## 3. 合并招标暂时不实现。以后看情况再说。
## 4. 提醒事项直接让 AI 助手对话窗口显示,用户进入工作台默认就会看到助手的消息,不用点开,但可以收起来。
## 5. “事找人待办中心”,改成 “待办中心” (注:几乎所有工作台都有这个问题)
![[Pasted image 20260710172254.png]]
## 6. 招标专员工作台增加后台智能体任务监控
### 1 模块定位
| 属性 | 说明 |
| -------- | ------------------------------------------------------------ |
| **模块名称** | 后台智能体任务(AI Agent Task Monitor |
| **位置** | 待办任务看板下方,在办项目列表上方 |
| **功能** | 实时展示后台异步 AI 智能体任务的执行进度,包括任务名称、关联项目、执行状态、进度百分比、关键中间结果、预计剩余时间等 |
| **刷新机制** | 每 3 秒轮询 / WebSocket 推送(推荐) |
### 2 任务类型清单
|任务图标|任务名称|触发时机|典型耗时|输出产物|
|---|---|---|---|---|
|🔍|AI 智慧寻源|委托确认后自动触发|2-10 分钟|潜在供应商名单 + 推荐报告|
|🔬|AI 雷同检测|投标文件上传完成后|1-8 分钟|雷同检测报告|
|📊|AI 价格分析|开标完成后|1-3 分钟|价格偏离度分析报告|
|👨‍🏫|AI 专家推荐|评标前准备阶段|30 秒-2 分钟|匹配专家列表|
|📄|AI 评标报告生成|评标打分完成后|1-2 分钟|评标报告初稿|
|🧩|AI 合并招标建议|委托生效时|1-3 分钟|合并招标建议书|
|📋|AI 资格初审|供应商报名后|1-5 分钟|资格预审结果|
### 3 任务状态与视觉规范
|状态|色条颜色|状态标签|进度条样式|说明|
|---|---|---|---|---|
|**待执行**|灰色(#9e9e9e)|⏸ 待执行|灰色空进度条|任务在队列中等待|
|**执行中**|橙色(#ff9800)|⏳ 执行中|橙色渐变进度条 + 百分比|正在运行,显示进度|
|**已完成**|绿色(#4caf50)|✅ 已完成|绿色实心进度条|任务成功完成|
|**失败**|红色(#f44336)|❌ 执行失败|红色进度条 + 错误标记|任务异常终止|
### 4 单条任务卡片结构
|区域|元素|说明|
|---|---|---|
|**左侧**|色条|6px 宽,颜色对应任务状态|
|**图标区**|任务图标 + 圆形背景|28px 圆,背景色与状态匹配|
|**任务信息**|任务名称(加粗)+ 关联项目名称|显示任务所属项目|
|**状态区**|状态标签 + 进度条 + 百分比|展示当前执行进度|
|**详情区**|关键中间结果|如“已匹配 8/20 家供应商”|
|**时间区**|预计剩余时间 / 完成时间|执行中显示剩余,已完成显示完成时间|
|**操作区**|取消 / 查看报告|执行中可取消,已完成可查看报告|
### 5 任务卡片示例数据
|任务|状态|进度|中间结果|预计剩余|操作|
|---|---|---|---|---|---|
|AI 智慧寻源|执行中|70%|已匹配:8 家供应商|2 分钟|取消|
|AI 雷同检测|执行中|30%|已扫描:3/10 份|5 分钟|取消|
|AI 价格分析|已完成|100%|发现 1 份报价偏离 >20%|09:35 完成|查看报告|
|AI 专家推荐|待执行|0%|—|—|—|
### 6 点击交互
|交互|说明|
|---|---|
|**点击任务卡片**|跳转至任务详情页(展示完整的执行日志和中间结果)|
|**点击“取消”**|弹出二次确认弹窗,确认后终止任务(仅限执行中状态)|
|**点击“查看报告”**|在新窗口打开或下载生成的报告文件|
|**悬停进度条**|显示详细进度信息(Tooltip|
### 7 智能体任务与 AI 助手的协同
当智能体任务状态发生变化时,AI助手“小滨”会同步推送主动提醒:
|任务状态变化|小滨推送内容|示例|
|---|---|---|
|任务完成|“✅ AI [任务名称] 已完成,请查看报告”|“✅ AI 价格分析已完成,发现 1 份报价偏离度 >20%”|
|任务失败|“❌ AI [任务名称] 执行失败,请重试或联系管理员”|“❌ AI 雷同检测执行失败,请检查投标文件格式”|
|关键节点|“📌 AI [任务名称] 已找到 [数量] 个潜在供应商”|“📌 AI 智慧寻源已找到 12 家潜在供应商”|
## 8 需求部门人员工作台添加AI 助手提醒
例:
```text
您的阀门采购项目已定标,下一步:进入 SRM 生成采购合同。(点击这里进入)
```
## 9 采购专员和需求部门人员角色重叠
合并成采购需求人员吧
## 10 评标专家工作台,添加小滨信息
例:
```
您有一个保温材料招标项目的评标邀请,你可以选择接受或者拒绝,点击这里查看详情。
```
## 11 审批人角色在OA中体现,不需要登录到 STP ,STP 不需要设计这个角色。
## 12 供应商工作台增加小滨信息
例:
```
您在聚丙烯项目中的报名已经通过,请点击这里查看招标文件,如果确认投标,可以点击这里交付投标保证金。
```
## 13 系统管理员工作台增加后台工作 Agent 查看内容
## 14 发起招标委托是采购需求人员的事,不是招标专员的事。
## 15 智能问答对话例子:
- 回时后直接提供链接或按钮供用户选择,不要“需要我带你过去吗?”这样的回答显得有点傻。
例:
```
user:如何发起招标委托
AI:发起招标委托很简单:①点击工作台或项目管理页的「发起委托」按钮;②在「填写需求」步骤用自然语言描述需求,点击「AI 智能解析」即可自动生成《招标委托书》;③确认预填单后提交审批即可。
现在点击[发起委托](./startMe)开始!
```
![[Pasted image 20260710182754.png]]
## 16 发起招标委托流程
- 要增加[导入采购计划]()按钮
- 添加[直接填写委托单]按钮
- 添加[选择模板]按钮(提供“自动”选项,让AI自己选择。)
## 17 **项目列表页面**
- 招标专员没有“发起委托”功能。
- 列表页面的各列表头增加排序按钮"↑""↓"。
- 搜索项目名称的两个搜索框功能重叠。
- 应增加“高级搜索”功能。
- 列名应该增加 委托人,委托单位。
- 增加提交审批按钮
- 可选择显示的列。
- 增加小滨演示例子:
```
问:帮我统计一下本年度所有项目的预算总和
答:据统计,本年度一共有13个项目,预算总和为 12348 万元。
```
## 18 项目详情页面
- 上方显示的流程不正确,正确流程是:
```mermaid
graph LR
A[发起委托]-->B[供应商寻源]-->C[招标立项]-->D[供应商投标]-->E[开标评标]-->F[定标]-->G[归档]
```
- 招标方案“AI生成”标签不需要。
- 没有显示委托人信息
- “招标方案”和“招标文件”是一回事,不要重复出现。
- 增加提交审批按钮
- 小滨助手示例:
```
user:请帮我生成招标方案
AI:好的,根据当前项目资料,我将以软件采购模板为基础为您生成完整招标方案,或者您也可以选择不同的模板。[确定按钮]
user:<选择确定>
AI:好的正在为您生成招标方案,您可在AI任务列表中查看它的进度。[任务列表]
```
## 19 供应商长短名单管理
- 不需要 “邀请招标分支”和 “公开招标分支“,招标方式在需求委托阶段已经确定。
- 不需要显示推荐部门列
## 20 AI供应商寻源页面
- AI寻源结果 没有跟项目关联。
- 不仅仅是互联网寻源,还有从 SRM 库寻源,从STP临时供应商池寻源。
## 21 **开标大厅**
- 不需要自动开标
## 22 定标审批页面
- 审批在OA中,不需要体现审批页面。
## 23 合同生成页面
- 合同生成在 SRM 中,STP 中没有此页面
## 24 合并招标页面
- 目前不需要此页面
## 25 专家管理页面
- 回避状态与项目相关,不需要在总体管理页面中体现。
- 抽取专家在项目流程,不在总体管理中。
# 26 供应商管理
- 不需要有新增供应商功能。
- 不需要供应商入库功能(审核通过即自动入库,无须单独操作)
## 27 合同管理页面
- 不需要。
## 28 供应商入库页面
- 不需要。
## 29 **审批中心**
- 不需要
## 30 **流程配置**
- 不需要,审批流程在 OA 中配置。
+48 -16
View File
@@ -67,6 +67,7 @@
"devDependencies": {
"@tailwindcss/vite": "4.1.12",
"@vitejs/plugin-react": "4.7.0",
"playwright": "^1.61.1",
"tailwindcss": "4.1.12",
"vite": "6.3.5"
},
@@ -4766,6 +4767,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -5667,22 +5715,6 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+2 -1
View File
@@ -67,6 +67,7 @@
"devDependencies": {
"@tailwindcss/vite": "4.1.12",
"@vitejs/plugin-react": "4.7.0",
"playwright": "^1.61.1",
"tailwindcss": "4.1.12",
"vite": "6.3.5"
},
@@ -87,4 +88,4 @@
"vite": "6.3.5"
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
import json
import os
from datetime import date
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
SHOTS = os.path.join(ROOT, "shots")
OUT = os.path.join(ROOT, "滨化智慧招标平台-前端界面截图汇总.docx")
BRAND = RGBColor(0x15, 0x65, 0xC0)
BRAND_HEX = "1565C0"
PAGE_W_IN = 6.3
with open(os.path.join(SHOTS, "manifest.json"), "r", encoding="utf-8") as f:
manifest = json.load(f)
order = []
for m in manifest:
if m["section"] not in order:
order.append(m["section"])
manifest.sort(key=lambda x: order.index(x["section"]))
doc = Document()
style = doc.styles["Normal"]
style.font.name = "Microsoft YaHei"
style.font.size = Pt(10.5)
style.element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
for sec in doc.sections:
sec.top_margin = Inches(0.8)
sec.bottom_margin = Inches(0.8)
sec.left_margin = Inches(0.8)
sec.right_margin = Inches(0.8)
def set_font(run, size=None, bold=None, color=None):
run.font.name = "Microsoft YaHei"
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
if size:
run.font.size = Pt(size)
if bold is not None:
run.font.bold = bold
if color is not None:
run.font.color.rgb = color
# 封面
for _ in range(4):
doc.add_paragraph()
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run("滨化集团智慧招标平台")
set_font(r, size=30, bold=True, color=BRAND)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run("Smart Tendering Platform (STP)")
set_font(r, size=14, color=RGBColor(0x88, 0x88, 0x88))
doc.add_paragraph()
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run("前端界面截图汇总")
set_font(r, size=22, bold=True)
for _ in range(6):
doc.add_paragraph()
sections_order = []
for m in manifest:
if m["section"] not in sections_order:
sections_order.append(m["section"])
meta = [
("文档类型", "前端界面(UI)截图汇总"),
("覆盖范围", "门户页 / 登录页 / 新增导航页 / 工作台 / 业务页 / 弹窗"),
("截图数量", f"{len(manifest)}"),
("章节数量", f"{len(sections_order)}"),
("生成日期", date.today().strftime("%Y 年 %m 月 %d")),
]
for k, v in meta:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run(f"{k}")
set_font(r, size=11, bold=True)
r = p.add_run(v)
set_font(r, size=11, color=RGBColor(0x44, 0x44, 0x44))
# 目录
doc.add_page_break()
p = doc.add_paragraph()
r = p.add_run("目 录")
set_font(r, size=18, bold=True, color=BRAND)
doc.add_paragraph()
for sec in sections_order:
cnt = sum(1 for m in manifest if m["section"] == sec)
p = doc.add_paragraph()
r = p.add_run(sec)
set_font(r, size=12, bold=True)
r = p.add_run(f" {cnt} 张)")
set_font(r, size=10.5, color=RGBColor(0x88, 0x88, 0x88))
fig_no = 0
current = None
for m in manifest:
img = os.path.join(SHOTS, m["file"])
if not os.path.exists(img):
continue
if m["section"] != current:
current = m["section"]
doc.add_page_break()
p = doc.add_paragraph()
r = p.add_run(current)
set_font(r, size=17, bold=True, color=BRAND)
pPr = p._p.get_or_add_pPr()
pbdr = pPr.makeelement(qn("w:pBdr"), {})
bottom = pbdr.makeelement(qn("w:bottom"), {
qn("w:val"): "single", qn("w:sz"): "12",
qn("w:space"): "4", qn("w:color"): BRAND_HEX,
})
pbdr.append(bottom)
pPr.append(pbdr)
doc.add_paragraph()
fig_no += 1
cap = doc.add_paragraph()
r = cap.add_run(f"{fig_no} {m['title']}")
set_font(r, size=11, bold=True, color=RGBColor(0x22, 0x22, 0x22))
with Image.open(img) as im:
w, h = im.size
ratio = h / w
width_in = PAGE_W_IN
height_in = width_in * ratio
max_h = 8.6
if height_in > max_h:
height_in = max_h
width_in = height_in / ratio
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.add_run().add_picture(img, width=Inches(width_in))
doc.add_paragraph()
doc.save(OUT)
print(f"已生成:{OUT}")
print(f"{fig_no} 张截图,{len(sections_order)} 个章节")
+145
View File
@@ -0,0 +1,145 @@
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.resolve(__dirname, "../../shots");
const BASE = process.env.BASE_URL || "http://localhost:5174";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const manifest = [];
let idx = 0;
async function expand(page) {
await page.evaluate(() => {
document.querySelectorAll(".h-screen").forEach((el) => {
el.style.height = "auto";
el.style.minHeight = "100vh";
el.style.overflow = "visible";
});
document.querySelectorAll(".overflow-hidden").forEach((el) => (el.style.overflow = "visible"));
document.querySelectorAll("main").forEach((el) => {
el.style.overflow = "visible";
el.style.height = "auto";
});
});
}
async function shot(page, section, title, { full = true, wait = 500, expandLayout = true } = {}) {
idx += 1;
const file = `${String(idx).padStart(2, "0")}.png`;
if (expandLayout) await expand(page).catch(() => {});
await sleep(wait);
await page.screenshot({ path: path.join(OUT, file), fullPage: full });
manifest.push({ file, section, title });
console.log(`[${idx}] ${section} / ${title} -> ${file}`);
}
async function goto(page, url) {
await page.goto(BASE + url, { waitUntil: "networkidle" }).catch(async () => {
await page.goto(BASE + url, { waitUntil: "domcontentloaded" });
});
await sleep(450);
}
async function clickIf(page, locator, wait = 300) {
const n = await locator.count();
if (n > 0) {
await locator.first().click().catch(() => {});
await sleep(wait);
}
}
async function switchRole(page, name) {
await page.locator("header button:has(svg.lucide-refresh-cw)").first().click();
await sleep(250);
await page.getByRole("menuitem", { name }).first().click();
await sleep(700);
}
async function run() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1.3 });
page.setDefaultTimeout(15000);
const A = "一、门户与登录";
await goto(page, "/portal");
await shot(page, A, "首页(门户)", { wait: 700 });
await clickIf(page, page.getByRole("link", { name: "交易信息" }));
await shot(page, A, "门户导航 · 交易信息页");
await goto(page, "/portal/policies");
await shot(page, A, "门户导航 · 政策法规页");
await goto(page, "/portal/guides");
await shot(page, A, "门户导航 · 操作指南页");
await goto(page, "/portal/faq");
await shot(page, A, "门户导航 · 常见问题页");
await goto(page, "/portal/procurement-platform");
await shot(page, A, "门户导航 · 采购平台页");
await goto(page, "/");
await shot(page, A, "登录页(含可拖拽小滨)", { full: false, expandLayout: false });
const B = "二、角色工作台";
await goto(page, "/app");
await shot(page, B, "招标专员工作台", { wait: 800 });
for (const rn of ["采购需求人员", "评标专家", "评标委员会工作领导小组", "供应商", "系统管理员", "合规 / 审计"]) {
await switchRole(page, rn);
await shot(page, B, `${rn}工作台`, { wait: 700 });
}
await switchRole(page, "招标专员");
const C = "三、核心业务页面";
for (const [url, title] of [
["/app/delegation", "发起招标委托"],
["/app/projects", "项目管理列表"],
["/app/projects/P2026001", "项目详情"],
["/app/projects/P2026001/shortlist", "供应商长短名单"],
["/app/projects/P2026001/sourcing", "AI智慧寻源"],
["/app/projects/P2026001/opening", "开标大厅"],
["/app/projects/P2026001/evaluation", "在线评标室"],
["/app/deposits", "保证金管理"],
["/app/suppliers", "供应商管理"],
["/app/experts", "专家管理"],
["/app/reports", "报表中心"],
["/app/exceptions", "异常决策"],
["/app/expert-tasks", "评标任务"],
["/app/bidding", "投标中心"],
["/app/audit-logs", "审计日志"],
["/app/collusion", "围串标分析"],
["/app/evidence", "存证验证"],
["/app/admin/users", "用户与权限管理"],
["/app/admin/keys", "密钥管理"],
["/app/settings", "系统设置"],
]) {
await goto(page, url);
await shot(page, C, title, { wait: 600 });
}
const D = "四、弹窗与抽屉";
await goto(page, "/app/suppliers");
await clickIf(page, page.locator("tbody tr"), 700);
await shot(page, D, "供应商详情抽屉", { full: false, expandLayout: false });
await goto(page, "/app/expert-tasks");
await clickIf(page, page.getByRole("button", { name: /确认邀请/ }), 500);
await clickIf(page, page.getByRole("button", { name: /签署评标承诺书/ }), 700);
await shot(page, D, "评标纪律承诺书弹窗", { full: false, expandLayout: false });
await goto(page, "/app/admin/keys");
await clickIf(page, page.getByRole("button", { name: /手动触发密钥漂移/ }), 700);
await shot(page, D, "密钥漂移二次授权弹窗", { full: false, expandLayout: false });
fs.writeFileSync(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
await browser.close();
console.log(`完成:共 ${manifest.length} 张截图`);
}
run().catch((e) => {
console.error(e);
fs.writeFileSync(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
process.exit(1);
});
+12 -17
View File
@@ -4,6 +4,12 @@ import { RoleProvider } from "./role-context";
import { AppLayout } from "./components/layout/AppLayout";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { PortalHome } from "./pages/PortalHome";
import { TradingInfoPage } from "./pages/portal/TradingInfoPage";
import { PoliciesPage } from "./pages/portal/PoliciesPage";
import { GuidesPage } from "./pages/portal/GuidesPage";
import { FaqPage } from "./pages/portal/FaqPage";
import { ProcurementPlatformPage } from "./pages/portal/ProcurementPlatformPage";
import { Workbench } from "./pages/Workbench";
import { Delegation } from "./pages/Delegation";
import { ProjectList } from "./pages/ProjectList";
@@ -11,15 +17,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";
@@ -27,10 +29,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() {
@@ -39,6 +38,12 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/portal" element={<PortalHome />} />
<Route path="/portal/trading-info" element={<TradingInfoPage />} />
<Route path="/portal/policies" element={<PoliciesPage />} />
<Route path="/portal/guides" element={<GuidesPage />} />
<Route path="/portal/faq" element={<FaqPage />} />
<Route path="/portal/procurement-platform" element={<ProcurementPlatformPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/app" element={<AppLayout />}>
<Route index element={<Workbench />} />
@@ -49,20 +54,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 />} />
{/* 评标专家 */}
@@ -75,7 +71,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>
);
}
+452 -111
View File
@@ -1,158 +1,499 @@
import { useState, useRef, useEffect } from "react";
import { Sparkles, X, Send, Bot, Lock } from "lucide-react";
import { useNavigate } from "react-router";
import { X, Send, Minus, Lock, ArrowRight, Sparkles, Bot } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "./ui/utils";
import { useRole } from "../role-context";
import type { RoleId } from "../roles";
interface MsgAction {
label: string;
to: string;
}
interface Msg {
role: "bot" | "user";
text: string;
actions?: MsgAction[];
}
type ProactiveType = "urgent" | "ai" | "todo" | "risk" | "suggestion";
interface Proactive {
type: ProactiveType;
title: string;
content: string;
to?: string;
actionLabel?: string;
}
const TYPE_STYLE: Record<ProactiveType, { bg: string; border: string; title: string; badge: string }> = {
urgent: { bg: "bg-[#fff3e0]", border: "border-[#ffcc80]", title: "text-[#e65100]", badge: "🔥" },
ai: { bg: "bg-[#e3f2fd]", border: "border-[#90caf9]", title: "text-[#0d47a1]", badge: "✅" },
todo: { bg: "bg-[#e8f0fe]", border: "border-[#a3c2f0]", title: "text-[#1565c0]", badge: "📋" },
risk: { bg: "bg-[#ffebee]", border: "border-[#ef9a9a]", title: "text-[#c62828]", badge: "⚠️" },
suggestion: { bg: "bg-[#f5f5f5]", border: "border-[#e0e0e0]", title: "text-[#455a64]", badge: "💡" },
};
// 各角色的主动提醒(最多 3~4 条,按优先级)
const PROACTIVE: Partial<Record<RoleId, Proactive[]>> = {
specialist: [
{ type: "urgent", title: "BID-2026-005 今日需开标", content: "「2026年度防腐保温工程框架」已到开标时间,请立即组织评标。", to: "/app/projects", actionLabel: "去处理" },
{ type: "ai", title: "AI 智慧寻源已完成", content: "「年产5万吨聚丙烯项目」已自动寻源,匹配到 12 家潜在供应商,请确认寻源结果。", to: "/app/projects/P2026005/sourcing", actionLabel: "查看结果" },
{ type: "todo", title: "您有 3 个委托单待受理", content: "来自采购需求人员的委托申请已提交,请及时受理并立项。", to: "/app/projects", actionLabel: "去受理" },
],
requester: [
{ type: "ai", title: "阀门采购项目已定标", content: "您的「氯碱车间耐腐蚀阀门采购」已完成定标,下一步请进入 SRM 生成采购合同。", to: "/app/projects", actionLabel: "查看项目" },
{ type: "todo", title: "2 个 AI 预填委托单待确认", content: "AI 已根据采购计划为您预填委托单,确认无误后即可提交立项。", to: "/app/delegation", actionLabel: "去确认" },
],
expert: [
{ type: "urgent", title: "保温材料招标项目评标邀请", content: "您有一个「保温材料招标项目」的评标邀请,可以选择接受或拒绝,点击查看详情。", to: "/app/expert-tasks", actionLabel: "查看详情" },
{ type: "todo", title: "《评标纪律承诺书》待签署", content: "进入评审前需先签署当前项目的评标纪律承诺书。", to: "/app/expert-tasks", actionLabel: "去签署" },
],
supplier: [
{ type: "ai", title: "聚丙烯项目报名已通过", content: "您在「年产5万吨聚丙烯项目」中的报名已通过,请查看招标文件;如确认投标,可缴纳投标保证金。", to: "/app/bidding", actionLabel: "查看招标文件" },
{ type: "urgent", title: "报名即将截止", content: "「污水处理提标改造工程」报名将于 7月15日截止,请尽快完成报名。", to: "/app/bidding", actionLabel: "去报名" },
],
committee: [
{ type: "risk", title: "异常决策待处理", content: "检测到 1 个项目触发异常上报(有效投标人不足 3 家),需领导小组决策。", to: "/app/exceptions", actionLabel: "去处理" },
],
auditor: [
{ type: "risk", title: "围串标风险提示", content: "AI 检测到 1 组投标文件相似度偏高,建议核查。", to: "/app/collusion", actionLabel: "去核查" },
],
};
const quickReplies = [
"如何发起招标委托?",
"邀请招标和公开招标有什么区别?",
"帮我统计本年度项目预算总和",
"在线评标怎么打分?",
"供应商如何入库?",
"公开招标和邀请招标的区别?",
];
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 } = useRole();
const [open, setOpen] = useState(false);
type PageType = "app" | "portal" | "login";
function getPointer(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) {
if ("touches" in e && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
if ("changedTouches" in e && e.changedTouches.length > 0) {
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
}
if ("clientX" in e) {
return { x: e.clientX, y: e.clientY };
}
return { x: 0, y: 0 };
}
const ENTRY_PROACTIVE: Proactive[] = [
{
type: "todo",
title: "欢迎使用智慧招标平台",
content: "您可以直接从首页查看交易信息、政策法规与操作指南。",
to: "/portal/trading-info",
actionLabel: "查看交易信息",
},
{
type: "ai",
title: "新用户建议先注册",
content: "供应商用户可先完成注册与实名认证,后续即可参与报名与投标。",
to: "/register",
actionLabel: "立即注册",
},
];
export function XiaobinAssistant({ pageType = "app" }: { pageType?: PageType }) {
const { sensitive, role, roleId } = useRole();
const navigate = useNavigate();
// 工作台默认展开;首页/登录页默认收起,避免遮挡主内容
const [open, setOpen] = useState(pageType === "app");
const [showChat, setShowChat] = useState(false);
const [input, setInput] = useState("");
const [proactive, setProactive] = useState<Proactive[]>(
pageType === "app" ? PROACTIVE[roleId] ?? [] : ENTRY_PROACTIVE,
);
const [msgs, setMsgs] = useState<Msg[]>([
{
role: "bot",
text: "您好,我是智能助手「小滨」👋 我可以为您解答系统操作与业务咨询。注意:我不参与任何业务决策哦。请问有什么可以帮您?",
text: "您好我是智能助手「小滨」👋 我可以为您解答系统操作与业务咨询。注意我不参与任何业务决策哦。请问有什么可以帮您",
},
]);
const endRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ dragging: false, dx: 0, dy: 0, sx: 0, sy: 0, moved: false });
const [pos, setPos] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const EDGE_GAP = 20;
const panelSize = open ? { w: 360, h: 520 } : { w: 68, h: 68 };
function clamp(x: number, y: number) {
const pad = EDGE_GAP;
const maxX = Math.max(pad, window.innerWidth - panelSize.w - pad);
const maxY = Math.max(pad, window.innerHeight - panelSize.h - pad);
return {
x: Math.min(Math.max(x, pad), maxX),
y: Math.min(Math.max(y, pad), maxY),
};
}
function startDrag(e: React.MouseEvent | React.TouchEvent) {
const p = getPointer(e);
dragRef.current.dragging = true;
dragRef.current.sx = p.x;
dragRef.current.sy = p.y;
dragRef.current.moved = false;
setDragging(true);
dragRef.current.dx = p.x - pos.x;
dragRef.current.dy = p.y - pos.y;
}
function snapToEdge(x: number, y: number) {
const pad = EDGE_GAP;
const maxX = Math.max(pad, window.innerWidth - panelSize.w - pad);
const maxY = Math.max(pad, window.innerHeight - panelSize.h - pad);
const targetX = x <= maxX / 2 ? pad : maxX;
return {
x: targetX,
y: Math.min(Math.max(y, pad), maxY),
};
}
// 切换角色时刷新主动提醒
useEffect(() => {
if (pageType === "app") setProactive(PROACTIVE[roleId] ?? []);
}, [roleId, pageType]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, open]);
if (showChat) endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, showChat]);
function answer(q: string) {
// 流程敏感期:拒答数据类问题
if (sensitive && dataKeywords.some((k) => q.includes(k))) {
return "当前为教学模式(流程敏感期),数据类问题暂无法回答。暂未到查看时间,请等待系统通知。我可以继续为您讲解系统操作哦~";
// 初始位置(右下角)
useEffect(() => {
const w = pageType === "app" ? 360 : 68;
const h = pageType === "app" ? 520 : 68;
const p = clamp(window.innerWidth - w - 32, window.innerHeight - h - 32);
setPos(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageType]);
useEffect(() => {
function onMove(e: MouseEvent | TouchEvent) {
if (!dragRef.current.dragging) return;
const p = getPointer(e);
if (!dragRef.current.moved && (Math.abs(p.x - dragRef.current.sx) > 4 || Math.abs(p.y - dragRef.current.sy) > 4)) {
dragRef.current.moved = true;
}
const next = clamp(p.x - dragRef.current.dx, p.y - dragRef.current.dy);
setPos(next);
}
const key = Object.keys(knowledge).find((k) => q.includes(k));
return key
? knowledge[key]
: "这是一个很好的问题。作为操作助手,我建议您查阅对应模块的帮助说明,或描述更具体的操作场景,我会尽力为您讲解系统使用方法。(温馨提示:业务决策需由有权限的人员完成)";
function onUp() {
if (!dragRef.current.dragging) return;
dragRef.current.dragging = false;
setDragging(false);
setPos((prev) => snapToEdge(prev.x, prev.y));
}
function onResize() {
setPos((prev) => clamp(prev.x, prev.y));
}
window.addEventListener("mousemove", onMove);
window.addEventListener("touchmove", onMove, { passive: false });
window.addEventListener("mouseup", onUp);
window.addEventListener("touchend", onUp);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("touchmove", onMove);
window.removeEventListener("mouseup", onUp);
window.removeEventListener("touchend", onUp);
window.removeEventListener("resize", onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const gradient = `linear-gradient(135deg, ${role.bannerFrom}, ${role.bannerTo})`;
const unread = proactive.length;
function answer(q: string): Knowledge {
if (sensitive && dataKeywords.some((k) => q.includes(k))) {
return {
text: "当前为流程敏感期(如开标前),数据类问题暂无法回答。请等待系统通知。我可以继续为您讲解系统操作哦~",
};
}
const hit = knowledge.find((k) => q.includes(k.key));
return (
hit?.ans ?? {
text:
"这是一个很好的问题。作为操作助手,我建议您查阅对应模块的帮助说明,或描述更具体的操作场景,我会尽力为您讲解系统使用方法。(温馨提示:业务决策需由有权限的人员完成)",
}
);
}
function send(text: string) {
const q = text.trim();
if (!q) return;
setShowChat(true);
setMsgs((m) => [...m, { role: "user", text: q }]);
setInput("");
setTimeout(() => {
setMsgs((m) => [...m, { role: "bot", text: answer(q) }]);
const a = answer(q);
setMsgs((m) => [...m, { role: "bot", text: a.text, actions: a.actions }]);
}, 500);
}
function handleAction(p: Proactive) {
if (p.to) navigate(p.to);
}
return (
<>
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-gradient-to-r from-violet-500 to-blue-600 px-4 py-3 text-white shadow-lg shadow-blue-500/30 transition-transform hover:scale-105"
>
<Sparkles className="size-5" />
<span className="text-sm font-medium"></span>
</button>
<div
className={cn(
"fixed z-50",
open ? "border bg-card shadow-2xl" : "border-0 bg-transparent shadow-none",
open ? "overflow-hidden" : "overflow-visible",
dragging ? "" : "transition-[left,top,width,height,border-radius] duration-260 ease-out",
)}
{open && (
<div className="fixed bottom-6 right-6 z-50 flex h-[540px] w-[380px] flex-col overflow-hidden rounded-2xl border bg-card shadow-2xl">
<div className="flex items-center justify-between bg-gradient-to-r from-violet-500 to-blue-600 px-4 py-3 text-white">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-full bg-white/20">
<Bot className="size-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-semibold"> · </div>
<div className="flex items-center gap-1 text-[11px] text-white/80">
<span className="size-1.5 rounded-full bg-emerald-300" /> 线 · &
</div>
</div>
</div>
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15">
<X className="size-4" />
</button>
</div>
{sensitive && (
<div className="flex items-center gap-1.5 border-b bg-amber-50 px-4 py-2 text-xs text-amber-700">
<Lock className="size-3.5" />
</div>
style={{
left: pos.x,
top: pos.y,
width: open ? 360 : 68,
height: open ? 520 : 68,
borderRadius: open ? 16 : "9999px",
}}
onClick={() => {
if (!open && !dragRef.current.moved) setOpen(true);
}}
>
{!open && (
<div
onMouseDown={startDrag}
onTouchStart={startDrag}
className="relative flex size-full cursor-grab items-center justify-center rounded-full text-white shadow-xl ring-1 ring-white/35 active:cursor-grabbing"
style={{ backgroundImage: gradient }}
aria-label="打开小滨 AI 助手"
>
<span className="pointer-events-none absolute inset-0 rounded-full animate-breathe" style={{ backgroundImage: gradient }} />
<span className="relative flex flex-col items-center leading-none">
<span className="flex size-8 items-center justify-center rounded-full bg-white/22 ring-1 ring-white/35">
<Bot className="size-4.5" />
</span>
<span className="mt-1 text-[10px] font-medium text-white/95"></span>
</span>
{unread > 0 && (
<span className="absolute right-0 top-0 z-10 flex size-5 translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full border-2 border-white bg-[#f44336] text-[10px] font-bold text-white shadow-sm">
{unread}
</span>
)}
<div className="flex-1 space-y-3 overflow-y-auto bg-muted/30 p-4">
{msgs.map((m, i) => (
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[80%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",
m.role === "user"
? "rounded-br-sm bg-primary text-primary-foreground"
: "rounded-bl-sm border bg-card text-card-foreground",
)}
>
{m.text}
</div>
</div>
))}
{msgs.length <= 1 && (
<div className="space-y-2 pt-2">
<p className="text-xs text-muted-foreground">:</p>
{quickReplies.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="block w-full rounded-lg border bg-card px-3 py-2 text-left text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
>
{q}
</button>
))}
</div>
)}
<div ref={endRef} />
</div>
<div className="flex items-center gap-2 border-t bg-card p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send(input)}
placeholder="输入您的问题…"
className="h-9 flex-1 rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary"
/>
<Button size="icon" onClick={() => send(input)} className="size-9 shrink-0">
<Send className="size-4" />
</Button>
</div>
</div>
)}
</>
<div
className={cn(
"flex h-full w-full flex-col",
open ? "opacity-100" : "pointer-events-none opacity-0",
dragging ? "" : "transition-opacity duration-180",
)}
>
{/* 头部 */}
<div
className="flex cursor-move items-center justify-between px-4 py-3 text-white"
style={{ backgroundImage: gradient }}
onMouseDown={startDrag}
onTouchStart={startDrag}
>
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-full bg-white/20 text-sm font-bold"></div>
<div className="leading-tight">
<div className="text-sm font-semibold"> AI </div>
<div className="flex items-center gap-1 text-[11px] text-white/85">
<span className="size-1.5 rounded-full bg-emerald-300" /> 线 ·
</div>
</div>
</div>
<button onClick={() => setOpen(false)} className="rounded-md p-1 hover:bg-white/15" title="收起">
<Minus className="size-4" />
</button>
</div>
{sensitive && (
<div className="flex items-center gap-1.5 border-b bg-amber-50 px-4 py-2 text-xs text-amber-700">
<Lock className="size-3.5" />
</div>
)}
<div className="flex-1 space-y-3 overflow-y-auto bg-muted/30 p-4">
{!showChat ? (
<>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
📢 {proactive.length}
</span>
{proactive.length > 0 && (
<button
onClick={() => setShowChat(true)}
className="text-xs text-primary hover:underline"
>
</button>
)}
</div>
{proactive.length === 0 && (
<div className="rounded-xl border bg-card p-4 text-center text-sm text-muted-foreground">
🎉
</div>
)}
{proactive.map((p, i) => {
const st = TYPE_STYLE[p.type];
return (
<div key={i} className={cn("animate-slide-up rounded-xl border p-3", st.bg, st.border)}>
<div className="flex items-start justify-between gap-2">
<div className={cn("text-[13px] font-bold", st.title)}>
{st.badge} {p.title}
</div>
<button
onClick={() => setProactive((ps) => ps.filter((_, idx) => idx !== i))}
className="text-muted-foreground/60 hover:text-muted-foreground"
>
<X className="size-3.5" />
</button>
</div>
<p className="mt-1 text-xs leading-relaxed text-[#4a5568]">{p.content}</p>
{p.to && (
<button
onClick={() => handleAction(p)}
className={cn("mt-2 inline-flex items-center gap-0.5 rounded-md bg-white/70 px-2 py-1 text-xs font-bold", st.title)}
>
{p.actionLabel ?? "去处理"} <ArrowRight className="size-3" />
</button>
)}
</div>
);
})}
<div className="pt-1">
<p className="mb-2 text-xs text-muted-foreground"></p>
<div className="space-y-2">
{quickReplies.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="block w-full rounded-lg border bg-card px-3 py-2 text-left text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
>
{q}
</button>
))}
</div>
</div>
</>
) : (
<>
<button onClick={() => setShowChat(false)} className="text-xs text-primary hover:underline">
</button>
{msgs.map((m, i) => (
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",
m.role === "user"
? "rounded-br-sm bg-primary text-primary-foreground"
: "rounded-bl-sm border bg-card text-card-foreground",
)}
>
<div>{m.text}</div>
{m.actions && m.actions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{m.actions.map((a) => (
<button
key={a.to}
onClick={() => navigate(a.to)}
className="inline-flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
>
<Sparkles className="size-3.5" /> {a.label}
</button>
))}
</div>
)}
</div>
</div>
))}
<div ref={endRef} />
</>
)}
</div>
<div className="flex items-center gap-2 border-t bg-card p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send(input)}
placeholder="输入您的问题…"
className="h-9 flex-1 rounded-full border bg-input-background px-3.5 text-sm outline-none focus:border-primary"
/>
<Button size="icon" onClick={() => send(input)} className="size-9 shrink-0 rounded-full" style={{ backgroundImage: gradient }}>
<Send className="size-4" />
</Button>
</div>
</div>
</div>
);
}
@@ -21,15 +21,15 @@ export function AppLayout() {
const initials = role.user.slice(0, 2);
return (
<div className="flex h-screen overflow-hidden bg-background">
<div className="flex h-screen overflow-hidden bg-[#f3f5f8]">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-16 shrink-0 items-center justify-between border-b bg-card px-6">
<header className="flex h-14 shrink-0 items-center justify-between border-b bg-white px-5">
<div className="relative w-96 max-w-[40vw]">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
placeholder="搜索项目编号 / 名称…"
className="h-9 w-full rounded-lg border bg-input-background pl-9 pr-3 text-sm outline-none focus:border-primary"
className="h-8 w-full border bg-[#f9fafb] pl-9 pr-3 text-sm outline-none focus:border-primary"
/>
</div>
<div className="flex items-center gap-3">
@@ -37,10 +37,10 @@ export function AppLayout() {
<button
onClick={() => setSensitive(!sensitive)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors",
"flex items-center gap-1.5 border px-2.5 py-1 text-xs transition-colors",
sensitive
? "border-amber-300 bg-amber-50 text-amber-700"
: "border-border text-muted-foreground hover:bg-muted",
: "border-border text-muted-foreground hover:bg-[#f3f4f6]",
)}
title="切换流程敏感期(影响小滨安全模式与数据可见性)"
>
@@ -50,7 +50,7 @@ export function AppLayout() {
{/* 角色切换 */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs outline-none hover:bg-muted">
<DropdownMenuTrigger className="flex items-center gap-2 border px-2.5 py-1 text-xs outline-none hover:bg-[#f3f4f6]">
<span className="size-2 rounded-full" style={{ backgroundColor: role.color }} />
<span className="font-medium text-foreground">{role.name}</span>
<RefreshCw className="size-3.5 text-muted-foreground" />
@@ -77,7 +77,7 @@ export function AppLayout() {
</DropdownMenuContent>
</DropdownMenu>
<button className="relative rounded-lg p-2 text-muted-foreground hover:bg-muted">
<button className="relative border p-1.5 text-muted-foreground hover:bg-[#f3f4f6]">
<Bell className="size-5" />
<span className="absolute right-1.5 top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] text-white">
5
@@ -85,7 +85,7 @@ export function AppLayout() {
</button>
<div className="h-6 w-px bg-border" />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 outline-none hover:bg-muted">
<DropdownMenuTrigger className="flex items-center gap-2.5 border px-2 py-1 outline-none hover:bg-[#f3f4f6]">
<Avatar className="size-8">
<AvatarFallback className="text-xs text-white" style={{ backgroundColor: role.color }}>
{initials}
@@ -109,7 +109,7 @@ export function AppLayout() {
</div>
</header>
<main className="flex-1 overflow-y-auto p-6">
<main className="flex-1 overflow-y-auto p-4">
<Outlet />
</main>
</div>
@@ -9,9 +9,9 @@ export function Sidebar() {
const { role } = useRole();
return (
<aside className="flex w-60 shrink-0 flex-col bg-sidebar text-sidebar-foreground">
<div className="flex h-16 items-center gap-2.5 px-5">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary">
<aside className="flex w-[232px] shrink-0 flex-col border-r bg-[#0b2a4a] text-sidebar-foreground">
<div className="flex h-14 items-center gap-2.5 px-4">
<div className="flex size-8 items-center justify-center bg-primary">
<Hexagon className="size-5 text-white" />
</div>
<div className="leading-tight">
@@ -21,7 +21,7 @@ export function Sidebar() {
</div>
{/* 当前角色标识 */}
<div className="mx-3 mb-2 flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2">
<div className="mx-3 mb-2 flex items-center gap-2 border border-white/10 bg-white/5 px-3 py-2">
<span className="size-2 rounded-full" style={{ backgroundColor: role.color }} />
<div className="leading-tight">
<div className="text-xs font-medium text-white">{role.name}</div>
@@ -39,10 +39,10 @@ export function Sidebar() {
to={item.to}
end={item.end}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
"flex items-center gap-3 px-3 py-2.5 text-sm transition-colors",
active
? "bg-primary text-white shadow-sm"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-white",
? "bg-primary text-white"
: "text-sidebar-foreground/80 hover:bg-[#143a61] hover:text-white",
)}
>
<Icon className="size-[18px]" />
@@ -53,7 +53,7 @@ export function Sidebar() {
</nav>
<div className="border-t border-sidebar-border/40 p-4">
<div className="rounded-lg bg-sidebar-accent/40 p-3">
<div className="border border-white/10 bg-sidebar-accent/40 p-3">
<div className="text-xs font-medium text-white">AI </div>
<p className="mt-1 text-[11px] leading-relaxed text-sidebar-foreground/60">
线
@@ -8,7 +8,7 @@ export function AiBadge({ label = "AI 生成", className }: { label?: string; cl
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md bg-gradient-to-r from-violet-500 to-blue-500 px-2 py-0.5 text-xs font-medium text-white",
"inline-flex items-center gap-1 rounded-md bg-brand-gradient px-2 py-0.5 text-xs font-medium text-white",
className,
)}
>
@@ -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-violet-200 bg-gradient-to-br from-violet-50 to-blue-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>
);
}
+65 -5
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}>
@@ -165,9 +224,9 @@ export function Delegation() {
</div>
</Card>
<Card className="gap-0 bg-gradient-to-br from-violet-50 to-blue-50 p-6">
<Card className="gap-0 border-primary/20 bg-secondary/50 p-6">
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-violet-500" />
<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>
</>
)}
{/* 步骤二:确认预填单 */}
@@ -106,7 +106,7 @@ function Detail({ item, onBack }: { item: Exception; onBack: () => void }) {
<Card className="gap-0 p-6">
<h3 className="mb-2 text-foreground"></h3>
<p className="text-sm leading-relaxed text-muted-foreground">{item.desc}</p>
<div className="mt-4 rounded-lg border border-violet-200 bg-gradient-to-br from-violet-50 to-blue-50 p-4">
<div className="mt-4 rounded-lg border border-primary/20 bg-secondary/50 p-4">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Sparkles className="size-4 text-violet-500" /> AI
</div>
+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>
);
}
+237 -104
View File
@@ -1,119 +1,199 @@
import { useState } from "react";
import { useNavigate, Link } from "react-router";
import { Hexagon, User, Lock, ShieldCheck, Sparkles, ArrowRight } from "lucide-react";
import { Hexagon, User, Lock, Smartphone, ShieldCheck, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { cn } from "../components/ui/utils";
import { XiaobinAssistant } from "../components/XiaobinAssistant";
type Mode = "employee" | "vendor";
type Tab = "account" | "mobile";
export function LoginPage() {
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>("vendor");
const [tab, setTab] = useState<Tab>("account");
const [account, setAccount] = useState("zhangwei");
const [pwd, setPwd] = useState("123456");
const [showPwd, setShowPwd] = useState(false);
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [remember, setRemember] = useState(true);
const [countdown, setCountdown] = useState(0);
function sendCode() {
if (!/^1[3-9]\d{9}$/.test(phone)) {
toast.error("请输入正确的手机号");
return;
}
toast.success("验证码已发送,请注意查收");
setCountdown(60);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
}
function submit(e: React.FormEvent) {
e.preventDefault();
if (mode === "employee") {
toast.info("正在跳转至滨化集团 IAM 统一身份认证…");
setTimeout(() => navigate("/app"), 800);
return;
}
navigate("/app");
}
return (
<div className="flex h-screen w-full overflow-hidden bg-slate-50">
{/* 左侧品牌区 */}
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-gradient-to-br from-[#0f1e3d] via-[#13306b] to-[#2563eb] p-12 text-white lg:flex">
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
<Hexagon className="size-6" />
</div>
<div>
<div className="text-lg font-semibold"></div>
<div className="text-sm text-white/60">Binhua Smart Tendering Platform</div>
</div>
<div className="min-h-screen bg-[#f3f5f8]">
<header className="border-b bg-[#0f4ea6] text-white">
<div className="mx-auto flex h-14 max-w-[1200px] items-center justify-between px-6">
<Link to="/portal" className="flex items-center gap-2">
<Hexagon className="size-5" />
<span className="text-base font-semibold"></span>
</Link>
<div className="text-xs text-white/80"> STP </div>
</div>
</header>
<div className="relative z-10">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-sm backdrop-blur">
<Sparkles className="size-4" /> AI · 线
</div>
<h1 className="text-white" style={{ fontSize: 40, lineHeight: 1.2, fontWeight: 700 }}>
AI
</h1>
<p className="mt-4 max-w-md text-white/70">
<main className="mx-auto grid max-w-[1200px] grid-cols-12 gap-4 px-6 py-8">
<section className="col-span-12 border bg-white p-5 lg:col-span-4">
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-xs text-muted-foreground">
<Link to="/register" className="ml-1 text-primary hover:underline"></Link>
</p>
<div className="mt-8 grid grid-cols-3 gap-4">
{[
{ k: "全流程", v: "9 大阶段闭环" },
{ k: "AI 预填", v: "委托效率 +60%" },
{ k: "链上存证", v: "投标哈希上链" },
].map((s) => (
<div key={s.k} className="rounded-xl bg-white/10 p-4 backdrop-blur">
<div className="text-lg font-semibold">{s.k}</div>
<div className="mt-1 text-xs text-white/60">{s.v}</div>
</div>
<div className="mt-4 flex border bg-[#f8fafc] p-1">
{(
[
{ id: "employee" as Mode, label: "内部员工" },
{ id: "vendor" as Mode, label: "外部账号" },
]
).map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={cn(
"flex-1 py-2 text-sm",
mode === m.id ? "bg-primary font-medium text-white" : "text-muted-foreground",
)}
>
{m.label}
</button>
))}
</div>
</div>
<div className="text-xs text-white/40">© 2026 · </div>
{/* 装饰 */}
<div className="pointer-events-none absolute -right-20 top-1/4 size-72 rounded-full bg-blue-400/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-10 right-1/3 size-60 rounded-full bg-violet-400/20 blur-3xl" />
</div>
{/* 右侧表单区 */}
<div className="flex flex-1 items-center justify-center p-8">
<div className="w-full max-w-sm">
<div className="mb-8 lg:hidden">
<div className="flex items-center gap-2">
<Hexagon className="size-7 text-primary" />
<span className="font-semibold"></span>
</div>
<div className="mt-3 flex border-b border-border">
{(
[
{ id: "account" as Tab, label: "账号登录" },
{ id: "mobile" as Tab, label: "手机验证码" },
]
).map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
"relative px-4 py-2 text-sm",
tab === t.id ? "font-medium text-primary" : "text-muted-foreground",
)}
>
{t.label}
{tab === t.id && <span className="absolute inset-x-1 bottom-0 h-0.5 bg-primary" />}
</button>
))}
</div>
<h2 className="text-foreground"></h2>
<p className="mt-1 text-sm text-muted-foreground"></p>
<form
className="mt-8 space-y-4"
onSubmit={(e) => {
e.preventDefault();
navigate("/app");
}}
>
<div>
<label className="mb-1.5 block text-sm"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={account}
onChange={(e) => setAccount(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none focus:border-primary"
placeholder="请输入账号"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type="password"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none focus:border-primary"
placeholder="请输入密码"
/>
</div>
</div>
<div className="flex gap-3">
<div className="relative flex-1">
<ShieldCheck className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none focus:border-primary"
placeholder="验证码"
/>
</div>
<div className="flex h-11 w-28 items-center justify-center rounded-lg bg-gradient-to-r from-blue-100 to-violet-100 font-mono tracking-widest text-primary select-none">
8 K 2 7
</div>
</div>
<form className="mt-4 space-y-3" onSubmit={submit}>
{tab === "account" ? (
<>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]"> / </label>
<div className="relative">
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={account}
onChange={(e) => setAccount(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-primary"
placeholder="请输入您的账号"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type={showPwd ? "text" : "password"}
value={pwd}
onChange={(e) => setPwd(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-10 text-sm outline-none transition-colors focus:border-primary"
placeholder="请输入您的密码"
/>
<button
type="button"
onClick={() => setShowPwd((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPwd ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
</>
) : (
<>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<div className="relative">
<Smartphone className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-primary"
placeholder="请输入11位手机号"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<div className="flex gap-3">
<div className="relative flex-1">
<ShieldCheck className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-11 w-full rounded-lg border bg-input-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-primary"
placeholder="6 位验证码"
/>
</div>
<button
type="button"
onClick={sendCode}
disabled={countdown > 0}
className="h-11 w-28 shrink-0 rounded-lg border border-primary text-sm font-medium text-primary transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:border-border disabled:text-muted-foreground"
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</button>
</div>
</div>
</>
)}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 text-muted-foreground">
<input type="checkbox" className="size-4 rounded border-border accent-[var(--primary)]" defaultChecked />
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="size-4 rounded border-border accent-[var(--primary)]"
/>
</label>
<button type="button" className="text-primary hover:underline">
@@ -121,19 +201,72 @@ export function LoginPage() {
</button>
</div>
<Button type="submit" className="h-11 w-full">
<ArrowRight className="size-4" />
<Button type="submit" className="h-10 w-full text-base">
</Button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
<Link to="/register" className="ml-1 font-medium text-primary hover:underline">
<div className="mt-4 text-center text-sm text-muted-foreground">
{mode === "employee" ? (
<span className="ml-1 text-muted-foreground/70"> HR </span>
) : (
<Link to="/register" className="ml-1 font-medium text-primary hover:underline">
</Link>
)}
</div>
<div className="mt-4 border bg-[#fffbea] px-3 py-2 text-xs text-[#92400e]">
SRM 使 SRM
</div>
<div className="mt-4 text-center">
<Link to="/portal" className="text-xs text-muted-foreground hover:text-primary">
</Link>
</div>
</div>
</div>
</section>
<section className="col-span-12 space-y-4 lg:col-span-8">
<div className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3 text-sm font-semibold"></div>
<ul className="space-y-1 p-4 text-sm">
{[
["[公开招标] 扣电测试柜框架", "2026-07-13"],
["[公开招标] 2026年橡胶密封件框架招标O型圈", "2026-07-13"],
["[公开招标] 莱州100MW分散式风电项目EPC工程总承包[变更公告]", "2026-07-13"],
["[公开招标] 旋塞阀框架包一 金属旋塞阀、包二 衬氟旋塞阀", "2026-07-10"],
["[公开招标] 改性TPU熟化料仓", "2026-07-09"],
].map(([title, date]) => (
<li key={title} className="flex items-center gap-3 border-b border-dashed py-1.5">
<span className="line-clamp-1 flex-1 hover:text-primary">{title}</span>
<span className="text-xs text-muted-foreground">{date}</span>
</li>
))}
</ul>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3 text-sm font-semibold"></div>
<div className="space-y-1 p-4 text-sm text-muted-foreground">
<p></p>
<p></p>
<p></p>
</div>
</div>
<div className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3 text-sm font-semibold"></div>
<div className="space-y-1 p-4 text-sm text-muted-foreground">
<p></p>
<p></p>
<p>使</p>
</div>
</div>
</div>
</section>
</main>
<XiaobinAssistant pageType="login" />
</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-violet-200 bg-gradient-to-r from-violet-50 to-blue-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>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { cn } from "../components/ui/utils";
import { PortalShell } from "./portal/PortalShell";
import { XiaobinAssistant } from "../components/XiaobinAssistant";
type Notice = { title: string; date: string };
const NOTICE_TABS = ["招标公告", "资格预审公告", "中标候选人公示", "结果公告", "终止公告"];
const BANNERS = [
{
title: "廉洁过节",
subtitle: "阳光招采 · 公开透明 · 全流程可追溯",
style: "from-[#1f4d8f] via-[#2e6dc2] to-[#5c8fe0]",
},
{
title: "上线试运行",
subtitle: "智慧招标平台已上线,欢迎采购人、供应商、专家使用",
style: "from-[#0d5f89] via-[#0f7da8] to-[#4ba6c9]",
},
{
title: "轮播图片2",
subtitle: "统一门户、统一身份、统一公告发布",
style: "from-[#2f5f3f] via-[#3b8460] to-[#66a37f]",
},
];
const NOTICE_DATA: Record<string, Notice[]> = {
: [
{ title: "[公开招标] 2026年地脚螺栓、不锈钢及五金紧固件框架招标", date: "2026-07-13" },
{ title: "[公开招标] 莱州100MW分散式风电项目EPC工程总承包[变更公告]", date: "2026-07-13" },
{ title: "[公开招标] 2026年橡胶密封件框架招标O型圈", date: "2026-07-13" },
{ title: "[公开招标] 旋塞阀框架包一 金属旋塞阀、包二 衬氟旋塞阀", date: "2026-07-10" },
{ title: "[公开招标] 改性TPU熟化料仓", date: "2026-07-09" },
],
: [
{ title: "滨州园区危化品运输服务资格预审公告", date: "2026-07-11" },
{ title: "公辅系统维保服务资格预审公告", date: "2026-07-10" },
{ title: "智慧园区网络安全服务资格预审公告", date: "2026-07-08" },
],
: [
{ title: "[公开招标] 牵引车租赁服务框架中标候选人公示", date: "2026-07-07" },
{ title: "[公开招标] 电池材料项目地质勘察工程中标候选人公示", date: "2026-07-06" },
{ title: "[公开招标] 四轮防爆巡检机器人系统中标候选人公示", date: "2026-06-26" },
],
: [
{ title: "[公开招标] HDPE管道管件框架招标中标结果公告", date: "2026-07-07" },
{ title: "[公开招标] 起重框架招标包一 单梁起重机中标结果公告", date: "2026-07-02" },
{ title: "[公开招标] 海阳绿电直连光伏组件项目中标结果公告", date: "2026-06-30" },
],
: [
{ title: "国标切断双偏心蝶阀框架招标废标公告", date: "2026-07-06" },
{ title: "TPU四期熟化料仓招标废标公告", date: "2026-07-01" },
{ title: "滨州碳酸锂项目冷却窑废标公告", date: "2026-06-24" },
],
};
function NoticeList({ items }: { items: Notice[] }) {
return (
<ul className="space-y-1.5">
{items.map((item) => (
<li
key={item.title}
className="flex items-center gap-3 border-b border-dashed border-[#e5e7eb] py-1.5 text-sm"
>
<span className="line-clamp-1 flex-1 text-[#1f2937] hover:text-primary">{item.title}</span>
<span className="shrink-0 text-xs text-[#6b7280]">{item.date}</span>
</li>
))}
</ul>
);
}
export function PortalHome() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(NOTICE_TABS[0]);
const [activeBanner, setActiveBanner] = useState(0);
const banner = BANNERS[activeBanner];
return (
<PortalShell active="home">
<section className="grid grid-cols-12 gap-4">
<div className="col-span-12 border bg-white lg:col-span-3">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h2 className="text-base font-semibold"></h2>
<p className="mt-1 text-xs text-[#6b7280]">
<button onClick={() => navigate("/register")} className="text-primary hover:underline"></button>
</p>
</div>
<div className="space-y-3 px-4 pb-4">
<input
placeholder="账号 / 手机号"
className="h-9 w-full border border-[#d9dee7] bg-white px-3 text-sm outline-none focus:border-primary"
/>
<input
type="password"
placeholder="密码"
className="h-9 w-full border border-[#d9dee7] bg-white px-3 text-sm outline-none focus:border-primary"
/>
<button
onClick={() => navigate("/")}
className="h-9 w-full bg-primary text-sm font-medium text-white hover:bg-[#0f4ea6]"
>
</button>
</div>
</div>
<div className="col-span-12 border bg-white lg:col-span-9">
<div className="relative h-[310px] overflow-hidden">
<div className={cn("absolute inset-0 bg-gradient-to-br", banner.style)} />
<div className="absolute inset-0 bg-black/20" />
<div className="relative flex h-full flex-col justify-end p-7 text-white">
<div className="text-3xl font-bold tracking-wide">{banner.title}</div>
<div className="mt-2 text-sm text-white/90">{banner.subtitle}</div>
</div>
<div className="absolute left-5 top-5 rounded bg-black/35 px-2 py-1 text-xs text-white/90">
</div>
<div className="absolute bottom-4 right-5 flex gap-2">
{BANNERS.map((b, idx) => (
<button
key={b.title}
onClick={() => setActiveBanner(idx)}
className={cn(
"rounded px-2.5 py-1 text-xs",
idx === activeBanner ? "bg-white text-[#1f2937]" : "bg-black/35 text-white",
)}
>
{b.title}
</button>
))}
</div>
</div>
</div>
</section>
<section className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h2 className="text-base font-semibold"></h2>
</div>
<div className="border-b px-3">
<div className="flex flex-wrap items-center">
{NOTICE_TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"relative px-4 py-3 text-sm",
activeTab === tab ? "font-semibold text-primary" : "text-[#4b5563] hover:text-primary",
)}
>
{tab}
{activeTab === tab && <span className="absolute inset-x-2 bottom-0 h-0.5 bg-primary" />}
</button>
))}
</div>
</div>
<div className="p-4">
<NoticeList items={NOTICE_DATA[activeTab]} />
</div>
</section>
<section className="grid grid-cols-12 gap-4">
<div className="col-span-12 border bg-white lg:col-span-6">
<div className="border-b bg-[#f8fafc] px-4 py-3 text-sm font-semibold"></div>
<div className="p-4">
<NoticeList
items={[
{ title: "蓬莱园区高温管道耐火衬里类物资供应商招募", date: "2026-07-10" },
{ title: "化学桨叶冷却机类供应商招募", date: "2026-07-10" },
{ title: "化学洗车平台服务类供应商招募", date: "2026-06-25" },
]}
/>
</div>
</div>
<div className="col-span-12 border bg-white lg:col-span-6">
<div className="border-b bg-[#f8fafc] px-4 py-3 text-sm font-semibold"> / </div>
<div className="grid grid-cols-2 gap-2 p-4 text-sm">
{[
"系统使用常见问题处理",
"常见问题帮助",
"供应商注册操作指南",
"投标文件制作软件操作手册",
"标证通申请-投标单位操作指南",
"浏览器切换IE模式",
].map((x) => (
<button key={x} className="border px-3 py-2 text-left hover:border-primary hover:bg-[#f8fbff]">
{x}
</button>
))}
</div>
</div>
</section>
<XiaobinAssistant pageType="portal" />
</PortalShell>
);
}
+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 bg-gradient-to-br from-violet-50/60 to-blue-50/60 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>
+563 -53
View File
@@ -1,81 +1,591 @@
import { useMemo, useState } from "react";
import { useNavigate, Link } from "react-router";
import { Hexagon, ArrowLeft, CheckCircle2 } from "lucide-react";
import {
Hexagon,
ArrowLeft,
ArrowRight,
UploadCloud,
CheckCircle2,
Loader2,
XCircle,
Building2,
UserRound,
ShieldCheck,
X,
Eye,
EyeOff,
Info,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { cn } from "../components/ui/utils";
const fields = [
{ label: "企业全称", placeholder: "请输入营业执照上的企业全称", col: 2 },
{ label: "统一社会信用代码", placeholder: "18 位信用代码" },
{ label: "企业类型", placeholder: "如:有限责任公司" },
{ label: "法定代表人", placeholder: "请输入法人姓名" },
{ label: "注册资本(万元)", placeholder: "请输入注册资本" },
{ label: "联系人", placeholder: "请输入联系人姓名" },
{ label: "联系电话", placeholder: "请输入手机号码" },
{ label: "电子邮箱", placeholder: "用于接收平台通知" },
{ label: "经营类别", placeholder: "如:自动化控制 / 环保工程" },
{ label: "企业地址", placeholder: "请输入详细注册地址", col: 2 },
type Registrant = "supplier" | "expert";
type AuditState = "idle" | "auditing" | "success" | "rejected";
const SUPPLIER_STEPS = ["企业信息", "联系人", "提交 / 自动审核"];
const EXPERT_STEPS = ["基本信息", "专业资质 / 收款", "提交审核"];
interface PwdRule {
label: string;
test: (v: string) => boolean;
}
const PWD_RULES: PwdRule[] = [
{ label: "长度 8~20 位", test: (v) => v.length >= 8 && v.length <= 20 },
{ label: "包含大写字母(A-Z)与小写字母(a-z)", test: (v) => /[A-Z]/.test(v) && /[a-z]/.test(v) },
{ label: "包含数字(0-9)与特殊字符(!@#$%^&*", test: (v) => /\d/.test(v) && /[!@#$%^&*()_+\-=]/.test(v) },
{ label: "禁止连续/重复字符(如 12345678、aaaa", test: (v) => !/(.)\1{3,}/.test(v) && !/0123|1234|2345|3456|4567|5678|6789/.test(v) },
{ label: "非常见弱口令(password、admin123 等)", test: (v) => !["password", "admin123", "12345678", "qwerty"].includes(v.toLowerCase()) },
];
function Field({
label,
placeholder,
required,
hint,
className,
type = "text",
readOnly,
}: {
label: string;
placeholder?: string;
required?: boolean;
hint?: string;
className?: string;
type?: string;
readOnly?: boolean;
}) {
return (
<div className={className}>
<label className="mb-1.5 block text-sm text-[#4a5568]">
{label} {required && <span className="text-[#e91e63]">*</span>}
</label>
<input
type={type}
readOnly={readOnly}
placeholder={placeholder}
className={cn(
"h-11 w-full rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary",
readOnly && "bg-muted text-muted-foreground",
)}
/>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
function StepBar({ steps, current }: { steps: string[]; current: number }) {
return (
<div className="flex items-center justify-center rounded-full border bg-white px-6 py-4 shadow-sm">
{steps.map((s, i) => (
<div key={s} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={cn(
"flex size-9 items-center justify-center rounded-full text-sm font-bold transition-colors",
i <= current ? "bg-brand-gradient text-white" : "bg-muted text-muted-foreground",
)}
>
{i < current ? <CheckCircle2 className="size-5" /> : i + 1}
</div>
<span className={cn("mt-2 text-xs", i <= current ? "font-medium text-primary" : "text-muted-foreground")}>{s}</span>
</div>
{i < steps.length - 1 && (
<div className={cn("mx-3 mb-6 h-0.5 w-16 rounded-full sm:w-24", i < current ? "bg-brand-gradient" : "bg-border")} />
)}
</div>
))}
</div>
);
}
function Notice({ items }: { items: string[] }) {
return (
<div className="rounded-xl border border-[#ffcc80] bg-[#fff3e0] p-4">
<div className="flex items-center gap-1.5 text-sm font-semibold text-[#e65100]">
<Info className="size-4" />
</div>
<ol className="mt-2 space-y-1.5 text-xs leading-relaxed text-[#bf360c]">
{items.map((t, i) => (
<li key={i}>
{i + 1}. {t}
</li>
))}
</ol>
</div>
);
}
export function RegisterPage() {
const navigate = useNavigate();
const [registrant, setRegistrant] = useState<Registrant>("supplier");
const [step, setStep] = useState(0);
const [ocr, setOcr] = useState<"none" | "scanning" | "done">("none");
const [audit, setAudit] = useState<AuditState>("idle");
const [products, setProducts] = useState<string[]>(["工业阀门", "管道配件"]);
const [productInput, setProductInput] = useState("");
const [pwd, setPwd] = useState("");
const [confirmPwd, setConfirmPwd] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [smsCountdown, setSmsCountdown] = useState(0);
const steps = registrant === "supplier" ? SUPPLIER_STEPS : EXPERT_STEPS;
const passedRules = useMemo(() => PWD_RULES.map((r) => r.test(pwd)), [pwd]);
const passedCount = passedRules.filter(Boolean).length;
const strength: 0 | 1 | 2 = passedCount >= 5 && pwd.length >= 12 ? 2 : passedCount >= 4 ? 1 : 0;
function switchRegistrant(r: Registrant) {
setRegistrant(r);
setStep(0);
setAudit("idle");
setOcr("none");
}
function triggerOcr() {
setOcr("scanning");
setTimeout(() => {
setOcr("done");
toast.success("营业执照识别完成,已自动回填企业信息");
}, 1500);
}
function addProduct() {
const v = productInput.trim();
if (v && products.length < 20 && !products.includes(v)) {
setProducts((p) => [...p, v]);
}
setProductInput("");
}
function sendSms() {
toast.success("验证码已发送");
setSmsCountdown(60);
const t = setInterval(() => {
setSmsCountdown((c) => {
if (c <= 1) {
clearInterval(t);
return 0;
}
return c - 1;
});
}, 1000);
}
function submitAudit() {
setStep(steps.length - 1);
setAudit("auditing");
setTimeout(() => {
setAudit("success");
}, 3800);
}
const isLastFormStep = step === steps.length - 2;
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-[#f5f7fa]">
<header className="flex h-16 items-center justify-between border-b bg-card px-8">
<div className="flex items-center gap-2">
<Hexagon className="size-6 text-primary" />
<span className="font-semibold"> · </span>
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-gradient">
<Hexagon className="size-5 text-white" />
</div>
<span className="font-semibold"> · </span>
</div>
<Link to="/" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary">
<ArrowLeft className="size-4" />
</Link>
</header>
<div className="mx-auto max-w-3xl px-6 py-10">
<div className="mb-6">
<h1 className="text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground">
1-3
</p>
<div className="mx-auto max-w-5xl px-6 py-8">
{/* 注册对象选择 */}
<div className="mb-6 flex justify-center gap-3">
{(
[
{ id: "supplier" as Registrant, label: "供应商注册", icon: Building2 },
{ id: "expert" as Registrant, label: "外部专家注册", icon: UserRound },
]
).map((r) => {
const Icon = r.icon;
return (
<button
key={r.id}
onClick={() => switchRegistrant(r.id)}
className={cn(
"flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all",
registrant === r.id
? "border-transparent bg-brand-gradient text-white shadow-sm"
: "border-border bg-white text-muted-foreground hover:border-primary hover:text-primary",
)}
>
<Icon className="size-4" /> {r.label}
</button>
);
})}
</div>
<div className="rounded-xl border bg-card p-8">
<h3 className="mb-5 text-foreground"></h3>
<form
className="grid grid-cols-2 gap-5"
onSubmit={(e) => {
e.preventDefault();
toast.success("注册申请已提交,平台正在进行资质审核");
setTimeout(() => navigate("/"), 1200);
}}
>
{fields.map((f) => (
<div key={f.label} className={f.col === 2 ? "col-span-2" : ""}>
<label className="mb-1.5 block text-sm">{f.label}</label>
<input
className="h-10 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary"
placeholder={f.placeholder}
<div className="mb-6 flex items-center justify-between gap-4">
<StepBar steps={steps} current={step} />
<div className="hidden shrink-0 items-center gap-1.5 rounded-full border border-[#4caf50] bg-[#e8f5e9] px-3 py-1.5 text-xs text-[#2e7d32] lg:flex">
<ShieldCheck className="size-3.5" /> AI ·
</div>
</div>
{/* ============ 供应商注册 ============ */}
{registrant === "supplier" && (
<>
{step === 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
<div className="rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
<div className="my-5 h-px bg-border" />
{/* 营业执照上传 */}
<div
onClick={triggerOcr}
className="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary bg-[#fafbfc] py-6 transition-colors hover:bg-secondary/40"
>
<UploadCloud className="mb-2 size-7 text-primary" />
<div className="text-sm font-medium text-primary">JPG/PNG/PDF10MB</div>
<div className="mt-1 text-xs text-muted-foreground">AI </div>
</div>
{ocr === "scanning" && (
<div className="mt-3 flex items-center gap-2 rounded-lg bg-[#fff8e1] px-3 py-2 text-sm text-[#e65100]">
<Loader2 className="size-4 animate-spin" /> ...
</div>
)}
{ocr === "done" && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[#a5d6a7] bg-[#e8f5e9] px-3 py-2 text-sm text-[#2e7d32]">
<CheckCircle2 className="size-4" /> 91370100XXXXXXXXXX
</div>
)}
<div className="mt-6 grid grid-cols-2 gap-x-5 gap-y-4">
<Field label="企业全称" required placeholder="系统自动识别或手动输入" className="col-span-2" />
<Field label="统一社会信用代码" required placeholder="18 位代码" />
<Field label="注册资本(万元)" required placeholder="请输入数字" />
<Field label="法定代表人" required placeholder="请输入法定代表人姓名" />
<Field label="行业分类" required placeholder="请选择或搜索行业(GB/T 4754" />
<Field label="注册地址" required placeholder="请填写详细地址" className="col-span-2" />
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<textarea
rows={2}
placeholder="系统自动识别或手动输入,不超过 500 字"
className="w-full resize-none rounded-lg border bg-input-background px-3 py-2 text-sm outline-none transition-colors focus:border-primary"
/>
</div>
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<div className="flex gap-2">
<input
value={productInput}
onChange={(e) => setProductInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addProduct())}
placeholder="输入产品名称,回车添加"
className="h-11 flex-1 rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary"
/>
<Button type="button" variant="outline" className="h-11" onClick={addProduct}>
+
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{products.map((p) => (
<span
key={p}
className="flex items-center gap-1 rounded-full border border-primary bg-secondary px-3 py-1 text-xs text-primary"
>
{p}
<button onClick={() => setProducts((ps) => ps.filter((x) => x !== p))}>
<X className="size-3" />
</button>
</span>
))}
</div>
</div>
</div>
</div>
<Notice
items={[
"请确保信息与营业执照一致",
"统一社会信用代码将作为唯一标识",
"提交后由 AI 即时自动审核",
"审核通过后可参与投标报名",
"已有 SRM 账号请直接登录",
]}
/>
</div>
))}
)}
<div className="col-span-2">
<label className="mb-1.5 block text-sm"> / </label>
<div className="flex h-28 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 text-sm text-muted-foreground hover:border-primary">
<CheckCircle2 className="mb-2 size-6 text-muted-foreground/60" />
PDF / JPG / PNG
{step === 1 && (
<div className="mx-auto max-w-2xl rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<div className="my-5 h-px bg-border" />
<div className="space-y-4">
<Field label="联系人姓名" required placeholder="请输入联系人姓名" hint="必填,最大 64 字符" />
<Field label="联系电话(手机号)" required placeholder="请输入 11 位手机号" hint="必填,格式校验,全局唯一" />
<Field label="电子邮箱" required placeholder="请输入常用邮箱" hint="必填,格式校验,全局唯一" />
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<div className="flex gap-3">
<input
placeholder="点击发送验证码"
className="h-11 flex-1 rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={sendSms}
disabled={smsCountdown > 0}
className="h-11 w-32 shrink-0 rounded-lg bg-brand-gradient text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{smsCountdown > 0 ? `${smsCountdown}s 后重发` : "发送验证码"}
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
{/* 密码 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<div className="relative">
<input
type={showPwd ? "text" : "password"}
value={pwd}
onChange={(e) => setPwd(e.target.value)}
placeholder="请设置登录密码"
className="h-11 w-full rounded-lg border bg-input-background px-3 pr-10 text-sm outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={() => setShowPwd((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPwd ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm text-[#4a5568]">
<span className="text-[#e91e63]">*</span>
</label>
<input
type={showPwd ? "text" : "password"}
value={confirmPwd}
onChange={(e) => setConfirmPwd(e.target.value)}
placeholder="请再次输入密码"
className={cn(
"h-11 w-full rounded-lg border bg-input-background px-3 text-sm outline-none transition-colors focus:border-primary",
confirmPwd && confirmPwd !== pwd && "border-destructive",
)}
/>
</div>
</div>
{/* 密码复杂度要求 */}
<div className="rounded-xl border bg-[#f8fafc] p-4">
<div className="text-sm font-medium text-[#374151]"></div>
<ul className="mt-2 space-y-1.5">
{PWD_RULES.map((r, i) => (
<li key={r.label} className="flex items-center gap-2 text-[13px]">
<span className={passedRules[i] ? "text-[#10b981]" : "text-[#ef4444]"}>{passedRules[i] ? "✔" : "✘"}</span>
<span className="text-[#374151]">{r.label}</span>
</li>
))}
</ul>
{/* 强度条 */}
<div className="mt-3 flex items-center gap-3">
<span className="text-xs text-muted-foreground"></span>
<div className="h-2 w-40 overflow-hidden rounded-full bg-[#e5e7eb]">
<div
className={cn(
"h-full rounded-full transition-all",
strength === 0 ? "w-1/3 bg-[#ef4444]" : strength === 1 ? "w-2/3 bg-[#f59e0b]" : "w-full bg-[#10b981]",
)}
/>
</div>
<span
className={cn(
"text-xs font-medium",
strength === 0 ? "text-[#ef4444]" : strength === 1 ? "text-[#f59e0b]" : "text-[#10b981]",
)}
>
{strength === 0 ? "弱" : strength === 1 ? "中" : "强"}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</>
)}
<div className="col-span-2 mt-2 flex items-center gap-3">
<Button type="submit" className="h-11 flex-1">
{/* ============ 外部专家注册 ============ */}
{registrant === "expert" && (
<>
{step === 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
<div className="rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
<div className="my-5 h-px bg-border" />
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
<Field label="真实姓名" required placeholder="请输入中文姓名" />
<Field label="性别" required placeholder="请选择" />
<Field label="身份证号码" required placeholder="请输入 18 位身份证号" hint="🔒 将使用 AES-256 加密存储" className="col-span-2" />
<Field label="出生日期" placeholder="由身份证自动识别" readOnly />
<Field label="年龄" placeholder="自动计算" readOnly />
<Field label="手机号码" required placeholder="请输入手机号" />
<Field label="电子邮箱" required placeholder="请输入常用邮箱" />
<Field label="工作单位" placeholder="请输入单位全称" />
<Field label="现从事专业" required placeholder="如:化工工艺、设备管理" />
<Field label="技术职称" required placeholder="高级 / 副高级 / 中级" />
<Field label="职称专业" placeholder="如:化学工程" />
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]">
/ <span className="text-[#e91e63]">*</span>
</label>
<div className="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary bg-[#fafbfc] py-5 hover:bg-secondary/40">
<UploadCloud className="mb-1.5 size-6 text-primary" />
<div className="text-sm text-primary"> PDF/JPG/PNG20MB</div>
</div>
</div>
</div>
</div>
<Notice
items={[
"所有信息须与身份证及职称证书一致",
"滨化集团将对信息进行真实性核验",
"入库专家须遵守《评委管理规定》",
"至少具备中级及以上职称方可入库",
"个人信息变更需及时在线更新",
]}
/>
</div>
)}
{step === 1 && (
<div className="mx-auto max-w-2xl rounded-2xl bg-card p-8 shadow-sm">
<h3 className="text-foreground"></h3>
<div className="my-5 h-px bg-border" />
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
<div className="col-span-2">
<label className="mb-1.5 block text-sm text-[#4a5568]"></label>
<input
placeholder="如:石油化工、反应工程、分离技术,逗号分隔"
className="h-11 w-full rounded-lg border bg-input-background px-3 text-sm outline-none focus:border-primary"
/>
</div>
<Field label="开户银行" placeholder="如:中国银行北京分行" />
<Field label="银行账号" placeholder="请输入收款账号" hint="🔒 将使用 AES-256 加密存储" />
<label className="col-span-2 mt-2 flex items-start gap-2 rounded-lg border border-[#ffcc80] bg-[#fff3e0] p-3 text-xs text-[#bf360c]">
<input type="checkbox" className="mt-0.5 size-4 accent-[var(--primary)]" />
</label>
</div>
</div>
)}
</>
)}
{/* ============ 提交 / 自动审核态(两种注册对象共用最后一步) ============ */}
{step === steps.length - 1 && (
<div className="mx-auto max-w-lg">
{audit === "auditing" && (
<div className="rounded-2xl bg-card p-10 text-center shadow-sm">
<Loader2 className="mx-auto size-14 animate-spin text-primary" />
<div className="mt-5 text-lg font-bold text-foreground">
{registrant === "supplier" ? "AI 正在审核企业信息..." : "已提交,AI 正在初审..."}
</div>
<div className="mt-2 text-sm text-muted-foreground"> 3~8 </div>
<div className="mt-3 text-[13px] text-muted-foreground/80">     </div>
</div>
)}
{audit === "success" && (
<div className="rounded-2xl bg-card p-10 text-center shadow-sm">
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-[#e8f5e9]">
<CheckCircle2 className="size-14 text-[#4caf50]" />
</div>
<div className="mt-5 text-2xl font-bold text-foreground">🎉 </div>
<p className="mt-2 text-sm text-muted-foreground">
{registrant === "supplier" ? "您的供应商账号已激活,正在自动登录..." : "您的专家账号已创建,请留意激活通知短信/邮件。"}
</p>
<div className="my-5 h-px bg-border" />
<div className="space-y-1.5 text-sm text-muted-foreground">
<div>
{registrant === "supplier" ? "供应商编号" : "专家编号"}
<span className="font-semibold text-foreground">
{registrant === "supplier" ? "SUP_20260709_0158" : "EXP_20260709_002"}
</span>
</div>
<div>
<span className="font-semibold text-foreground">138****8001</span>
</div>
</div>
<div className="mt-5 rounded-lg border border-[#ffcc80] bg-[#fff3e0] px-4 py-2.5 text-xs text-[#e65100]">
📌
</div>
<Button className="mt-6 h-11 w-full bg-brand-gradient hover:opacity-90" onClick={() => navigate("/app")}>
<ArrowRight className="size-4" />
</Button>
</div>
)}
{audit === "rejected" && (
<div className="rounded-2xl bg-card p-10 text-center shadow-sm">
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-[#ffcdd2]">
<XCircle className="size-14 text-[#c62828]" />
</div>
<div className="mt-5 text-2xl font-bold text-[#c62828]"></div>
<div className="mt-5 rounded-lg border bg-[#fafafa] p-4 text-left text-[13px] text-[#c62828]">
<div> </div>
<div> </div>
<div className="mt-2 text-[#666]">💡 </div>
</div>
<Button
className="mt-6 h-11 w-full bg-brand-gradient hover:opacity-90"
onClick={() => {
setAudit("idle");
setStep(0);
}}
>
</Button>
</div>
)}
</div>
)}
{/* 底部导航按钮 */}
{audit === "idle" && (
<div className="mx-auto mt-6 flex max-w-5xl items-center justify-end gap-3">
{step > 0 && (
<Button variant="outline" className="h-11 px-6" onClick={() => setStep((s) => s - 1)}>
</Button>
<Button type="button" variant="outline" className="h-11" onClick={() => navigate("/")}>
)}
{isLastFormStep ? (
<Button className="h-11 bg-brand-gradient px-8 hover:opacity-90" onClick={submitAudit}>
</Button>
</div>
</form>
</div>
) : (
<Button className="h-11 bg-brand-gradient px-8 hover:opacity-90" onClick={() => setStep((s) => s + 1)}>
<ArrowRight className="size-4" />
</Button>
)}
</div>
)}
</div>
</div>
);
+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>
);
}
+49 -43
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>
);
}
@@ -122,26 +128,49 @@ function RoleExtra({ extra }: { extra: string }) {
if (extra === "specialist") {
return (
<Card className="gap-0 border-violet-200 bg-gradient-to-br from-violet-50 to-blue-50 p-5">
<Card className="gap-0 border-primary/20 bg-secondary/50 p-5">
<div className="mb-2 flex items-center gap-2">
<GitMerge className="size-4 text-violet-500" />
<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-violet-600"> {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>
);
}
@@ -150,7 +179,7 @@ function RoleExtra({ extra }: { extra: string }) {
return (
<Card className="gap-0 p-5">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="size-4 text-violet-500" />
<Sparkles className="size-4 text-primary" />
<h4 className="text-foreground">AI </h4>
</div>
<div className="space-y-2.5">
@@ -164,7 +193,7 @@ function RoleExtra({ extra }: { extra: string }) {
>
<div className="flex items-center justify-between">
<span className="line-clamp-1 flex-1 text-sm text-foreground">{p.name}</span>
<span className="ml-2 shrink-0 rounded bg-violet-50 px-1.5 py-0.5 text-xs text-violet-600">
<span className="ml-2 shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs text-primary">
{p.match}%
</span>
</div>
@@ -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>
);
}
@@ -11,7 +11,7 @@ const users = [
{ id: "U1", name: "张伟", dept: "招标采购中心", role: "招标专员", status: "启用" },
{ id: "U2", name: "刘晓敏", dept: "氯碱生产部", role: "需求部门人员", status: "启用" },
{ id: "U3", name: "陈晨", dept: "采购供应部", role: "采购专员", status: "启用" },
{ id: "U4", name: "王立强", dept: "公司领导", role: "审批人", status: "启用" },
{ id: "U4", name: "王立强", dept: "集团领导", role: "审批人", status: "启用" },
{ id: "U5", name: "孙建华", dept: "评标委员会", role: "领导小组", status: "启用" },
{ id: "U6", name: "李审计", dept: "审计监察部", role: "合规/审计", status: "停用" },
];
@@ -0,0 +1,49 @@
import { useState } from "react";
import { PortalShell } from "./PortalShell";
import { cn } from "../../components/ui/utils";
const FAQS = [
{
q: "供应商账号如何注册?",
a: "点击首页「用户登录」中的「供应商」入口,进入注册页后按营业执照信息与联系人信息提交即可。",
},
{
q: "投标文件制作软件在哪里下载?",
a: "请在「操作指南」页面下载「投标文件制作专用软件」,并按手册完成安装配置。",
},
{
q: "忘记密码怎么办?",
a: "在登录页点击「忘记密码」,通过注册手机号验证后可重置密码。",
},
{
q: "为什么页面显示异常?",
a: "建议使用最新版 Chrome 或 Edge;如为内网环境,请按指南开启 IE 兼容模式。",
},
];
export function FaqPage() {
const [open, setOpen] = useState(0);
return (
<PortalShell active="faq">
<section className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h1 className="text-base font-semibold"></h1>
</div>
<div className="divide-y">
{FAQS.map((item, idx) => (
<div key={item.q} className="px-4 py-3">
<button
onClick={() => setOpen(idx)}
className={cn("w-full text-left text-sm", open === idx ? "font-medium text-primary" : "")}
>
Q{idx + 1}. {item.q}
</button>
{open === idx && <p className="mt-2 text-sm text-muted-foreground">{item.a}</p>}
</div>
))}
</div>
</section>
</PortalShell>
);
}
@@ -0,0 +1,34 @@
import { PortalShell } from "./PortalShell";
const GUIDES = [
"电子招投标平台-供应商注册操作指南",
"电子招标投标平台投标文件制作软件操作手册",
"标证通申请-投标单位操作指南",
"投标文件制作专用软件安装说明",
"浏览器切换IE模式",
"系统使用常见问题处理",
];
export function GuidesPage() {
return (
<PortalShell active="guide">
<section className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h1 className="text-base font-semibold"></h1>
</div>
<ul className="p-4 text-sm">
{GUIDES.map((g, i) => (
<li key={g} className="flex items-center gap-3 border-b border-dashed py-2">
<span className="text-muted-foreground">{String(i + 1).padStart(2, "0")}.</span>
<button className="line-clamp-1 flex-1 text-left hover:text-primary">{g}</button>
<button className="shrink-0 border px-2 py-1 text-xs hover:border-primary hover:text-primary">
</button>
</li>
))}
</ul>
</section>
</PortalShell>
);
}
@@ -0,0 +1,35 @@
import { PortalShell } from "./PortalShell";
const POLICIES = [
{ title: "中华人民共和国招标投标法", source: "国家法律法规", date: "2026-03-12" },
{ title: "电子招标投标办法", source: "国家发展改革委", date: "2026-02-20" },
{ title: "滨化集团采购管理办法(2026版)", source: "集团制度", date: "2026-01-15" },
{ title: "供应商管理与准入规范", source: "集团制度", date: "2025-12-10" },
{ title: "评标专家行为规范与回避制度", source: "集团制度", date: "2025-11-08" },
];
export function PoliciesPage() {
return (
<PortalShell active="policy">
<section className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h1 className="text-base font-semibold"></h1>
</div>
<div className="grid grid-cols-12 gap-3 p-4">
{POLICIES.map((p) => (
<button
key={p.title}
className="col-span-12 border px-4 py-3 text-left hover:border-primary hover:bg-[#f8fbff] lg:col-span-6"
>
<div className="text-sm font-medium">{p.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{p.source} {p.date}
</div>
</button>
))}
</div>
</section>
</PortalShell>
);
}
@@ -0,0 +1,74 @@
import { Link } from "react-router";
import { Hexagon } from "lucide-react";
import { cn } from "../../components/ui/utils";
import type { ReactNode } from "react";
type NavKey = "home" | "trading" | "policy" | "guide" | "faq" | "platform";
const NAV_ITEMS: { key: NavKey; label: string; to: string }[] = [
{ key: "home", label: "首页", to: "/portal" },
{ key: "trading", label: "交易信息", to: "/portal/trading-info" },
{ key: "policy", label: "政策法规", to: "/portal/policies" },
{ key: "guide", label: "操作指南", to: "/portal/guides" },
{ key: "faq", label: "常见问题", to: "/portal/faq" },
{ key: "platform", label: "采购平台", to: "/portal/procurement-platform" },
];
export function PortalShell({
active,
children,
}: {
active: NavKey;
children: ReactNode;
}) {
return (
<div className="min-h-screen bg-[#f5f7fa] text-[#1f2937]">
<header className="border-b bg-white">
<div className="mx-auto flex h-11 max-w-[1260px] items-center px-6 text-xs text-[#6b7280]">
<span></span>
</div>
<div className="border-t border-b bg-[#0f4ea6]">
<div className="mx-auto flex h-16 max-w-[1260px] items-center px-6">
<Link to="/portal" className="flex items-center gap-2 text-white">
<Hexagon className="size-6" />
<div className="leading-tight">
<div className="text-lg font-semibold"></div>
<div className="text-[11px] text-white/80">Smart Tendering Platform</div>
</div>
</Link>
</div>
</div>
<nav className="border-b bg-white">
<div className="mx-auto flex h-11 max-w-[1260px] items-center px-6">
{NAV_ITEMS.map((item, i) => (
<Link
key={item.key}
to={item.to}
className={cn(
"mr-1 border-r px-5 text-sm leading-8",
i === NAV_ITEMS.length - 1 && "border-r-0",
item.key === active
? "bg-primary font-semibold text-white"
: "text-[#374151] hover:bg-[#f2f6fc] hover:text-primary",
)}
>
{item.label}
</Link>
))}
</div>
</nav>
</header>
<main className="mx-auto max-w-[1260px] space-y-4 px-6 py-4">{children}</main>
<footer className="mt-6 border-t bg-white">
<div className="mx-auto max-w-[1260px] space-y-2 px-6 py-5 text-xs text-[#6b7280]">
<div> </div>
<div> </div>
<div></div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,64 @@
import { useNavigate } from "react-router";
import { Building2, Briefcase, UserRound, Users } from "lucide-react";
import { PortalShell } from "./PortalShell";
const ENTRIES = [
{
title: "采购人入口",
desc: "发起采购、发布公告、组织评标与定标管理",
icon: Building2,
action: "进入采购人工作台",
},
{
title: "供应商入口",
desc: "注册报名、下载文件、在线投标、保证金管理",
icon: Briefcase,
action: "进入供应商工作台",
},
{
title: "专家入口",
desc: "接受邀请、签署承诺、在线评标与评分提交",
icon: UserRound,
action: "进入专家工作台",
},
{
title: "其他入口",
desc: "审计监察、系统管理等角色入口",
icon: Users,
action: "进入其他角色入口",
},
];
export function ProcurementPlatformPage() {
const navigate = useNavigate();
return (
<PortalShell active="platform">
<section className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h1 className="text-base font-semibold"></h1>
</div>
<div className="grid grid-cols-12 gap-4 p-4">
{ENTRIES.map((entry) => {
const Icon = entry.icon;
return (
<div key={entry.title} className="col-span-12 border p-4 lg:col-span-6">
<div className="flex items-center gap-2">
<Icon className="size-5 text-primary" />
<h2 className="text-sm font-semibold">{entry.title}</h2>
</div>
<p className="mt-2 text-sm text-muted-foreground">{entry.desc}</p>
<button
onClick={() => navigate("/")}
className="mt-4 border border-primary px-3 py-1.5 text-xs text-primary hover:bg-[#eef5ff]"
>
{entry.action}
</button>
</div>
);
})}
</div>
</section>
</PortalShell>
);
}
@@ -0,0 +1,75 @@
import { useState } from "react";
import { cn } from "../../components/ui/utils";
import { PortalShell } from "./PortalShell";
type Notice = { title: string; date: string };
const NOTICE_TABS = ["招标公告", "资格预审公告", "中标候选人公示", "结果公告", "终止公告"];
const NOTICE_DATA: Record<string, Notice[]> = {
: [
{ title: "[公开招标] 扣电测试柜框架", date: "2026-07-13" },
{ title: "[公开招标] 2026年地脚螺栓、不锈钢及五金紧固件框架招标", date: "2026-07-13" },
{ title: "[公开招标] 莱州100MW分散式风电项目EPC工程总承包[变更公告]", date: "2026-07-13" },
{ title: "[公开招标] TPU掺混特种线项目FFS包装机[变更公告]", date: "2026-07-13" },
],
: [
{ title: "滨州园区危化品运输服务资格预审公告", date: "2026-07-11" },
{ title: "公辅系统维保服务资格预审公告", date: "2026-07-10" },
{ title: "智慧园区网络安全服务资格预审公告", date: "2026-07-08" },
],
: [
{ title: "[公开招标] 牵引车租赁服务框架中标候选人公示", date: "2026-07-07" },
{ title: "[公开招标] 电池材料项目地质勘察工程中标候选人公示", date: "2026-07-06" },
{ title: "[公开招标] 四轮防爆巡检机器人系统中标候选人公示", date: "2026-06-26" },
],
: [
{ title: "[公开招标] HDPE管道管件框架招标中标结果公告", date: "2026-07-07" },
{ title: "[公开招标] 起重框架招标包一 单梁起重机中标结果公告", date: "2026-07-02" },
{ title: "[公开招标] 海阳绿电直连光伏组件项目中标结果公告", date: "2026-06-30" },
],
: [
{ title: "国标切断双偏心蝶阀框架招标废标公告", date: "2026-07-06" },
{ title: "TPU四期熟化料仓招标废标公告", date: "2026-07-01" },
{ title: "滨州碳酸锂项目冷却窑废标公告", date: "2026-06-24" },
],
};
export function TradingInfoPage() {
const [activeTab, setActiveTab] = useState(NOTICE_TABS[0]);
return (
<PortalShell active="trading">
<section className="border bg-white">
<div className="border-b bg-[#f8fafc] px-4 py-3">
<h1 className="text-base font-semibold"></h1>
</div>
<div className="border-b px-3">
<div className="flex flex-wrap items-center">
{NOTICE_TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"relative px-4 py-3 text-sm",
activeTab === tab ? "font-semibold text-primary" : "text-[#4b5563] hover:text-primary",
)}
>
{tab}
{activeTab === tab && <span className="absolute inset-x-2 bottom-0 h-0.5 bg-primary" />}
</button>
))}
</div>
</div>
<ul className="space-y-1 p-4">
{NOTICE_DATA[activeTab].map((n) => (
<li key={n.title} className="flex items-center gap-3 border-b border-dashed py-2 text-sm">
<span className="line-clamp-1 flex-1 hover:text-primary">{n.title}</span>
<span className="text-xs text-muted-foreground">{n.date}</span>
</li>
))}
</ul>
</section>
</PortalShell>
);
}
+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: "#2563eb",
bannerFrom: "#0f1e3d",
bannerTo: "#2563eb",
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: "#f59e0b",
bannerFrom: "#7c2d12",
bannerTo: "#f59e0b",
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: "#8b5cf6",
bannerFrom: "#4c1d95",
bannerTo: "#8b5cf6",
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",
+86 -23
View File
@@ -2,43 +2,46 @@
:root {
--font-size: 14px;
--background: #f8fafc;
--foreground: #1e293b;
--background: #f3f5f8;
--foreground: #1a2332;
--card: #ffffff;
--card-foreground: #1e293b;
--card-foreground: #1a2332;
--popover: #ffffff;
--popover-foreground: #1e293b;
--primary: #2563eb;
--popover-foreground: #1a2332;
--primary: #1565c0;
--primary-foreground: #ffffff;
--secondary: #eff6ff;
--secondary-foreground: #1d4ed8;
--secondary: #eef4fb;
--secondary-foreground: #0d47a1;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #eff6ff;
--accent-foreground: #1d4ed8;
--accent: #eef4fb;
--accent-foreground: #0d47a1;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: transparent;
--input-background: #f8fafc;
--input-background: #f9fafb;
--switch-background: #cbd5e1;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: #2563eb;
--chart-1: #2563eb;
--chart-2: #0ea5e9;
--chart-3: #14b8a6;
--chart-4: #f59e0b;
--chart-5: #8b5cf6;
--radius: 0.625rem;
--sidebar: #0f1e3d;
--sidebar-foreground: #cbd5e1;
--sidebar-primary: #2563eb;
--ring: #1565c0;
--chart-1: #1565c0;
--chart-2: #00897b;
--chart-3: #f59e0b;
--chart-4: #7e57c2;
--chart-5: #ef5350;
--radius: 0.35rem;
/* 品牌渐变(全平台统一科技蓝,呼应滨化 LOGO): #0d47a1 → #1e88e5 */
--brand-from: #0f4ea6;
--brand-to: #0c428d;
--sidebar: #0d2137;
--sidebar-foreground: #d6e4f5;
--sidebar-primary: #1e88e5;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #1e3a8a;
--sidebar-accent: #16375a;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #1e293b;
--sidebar-ring: #2563eb;
--sidebar-border: #16375a;
--sidebar-ring: #1e88e5;
}
.dark {
@@ -117,6 +120,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-brand-from: var(--brand-from);
--color-brand-to: var(--brand-to);
}
@layer base {
@@ -179,3 +184,61 @@
line-height: 1.5;
}
}
/* ===== 平台品牌工具类与动效(统一科技蓝) ===== */
@layer utilities {
/* 品牌渐变背景 */
.bg-brand-gradient {
background-image: linear-gradient(135deg, var(--brand-from), var(--brand-to));
}
.text-brand-gradient {
background-image: linear-gradient(135deg, var(--brand-from), var(--brand-to));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
/* AI 助手呼吸光环 */
@keyframes brand-breathe {
0% {
transform: scale(1);
opacity: 0.55;
}
100% {
transform: scale(1.55);
opacity: 0;
}
}
.animate-breathe {
animation: brand-breathe 2s ease-out infinite;
}
/* 列表/卡片入场(自下而上淡入) */
@keyframes brand-slide-up {
0% {
opacity: 0;
transform: translateY(16px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: brand-slide-up 0.5s ease forwards;
}
/* 待办数字变化呼吸提醒 */
@keyframes brand-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.animate-pulse-soft {
animation: brand-pulse 1s ease-in-out 2;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+187
View File
@@ -0,0 +1,187 @@
[
{
"file": "01.png",
"section": "一、门户与登录",
"title": "首页(门户)"
},
{
"file": "02.png",
"section": "一、门户与登录",
"title": "门户导航 · 交易信息页"
},
{
"file": "03.png",
"section": "一、门户与登录",
"title": "门户导航 · 政策法规页"
},
{
"file": "04.png",
"section": "一、门户与登录",
"title": "门户导航 · 操作指南页"
},
{
"file": "05.png",
"section": "一、门户与登录",
"title": "门户导航 · 常见问题页"
},
{
"file": "06.png",
"section": "一、门户与登录",
"title": "门户导航 · 采购平台页"
},
{
"file": "07.png",
"section": "一、门户与登录",
"title": "登录页(含可拖拽小滨)"
},
{
"file": "08.png",
"section": "二、角色工作台",
"title": "招标专员工作台"
},
{
"file": "09.png",
"section": "二、角色工作台",
"title": "采购需求人员工作台"
},
{
"file": "10.png",
"section": "二、角色工作台",
"title": "评标专家工作台"
},
{
"file": "11.png",
"section": "二、角色工作台",
"title": "评标委员会工作领导小组工作台"
},
{
"file": "12.png",
"section": "二、角色工作台",
"title": "供应商工作台"
},
{
"file": "13.png",
"section": "二、角色工作台",
"title": "系统管理员工作台"
},
{
"file": "14.png",
"section": "二、角色工作台",
"title": "合规 / 审计工作台"
},
{
"file": "15.png",
"section": "三、核心业务页面",
"title": "发起招标委托"
},
{
"file": "16.png",
"section": "三、核心业务页面",
"title": "项目管理列表"
},
{
"file": "17.png",
"section": "三、核心业务页面",
"title": "项目详情"
},
{
"file": "18.png",
"section": "三、核心业务页面",
"title": "供应商长短名单"
},
{
"file": "19.png",
"section": "三、核心业务页面",
"title": "AI智慧寻源"
},
{
"file": "20.png",
"section": "三、核心业务页面",
"title": "开标大厅"
},
{
"file": "21.png",
"section": "三、核心业务页面",
"title": "在线评标室"
},
{
"file": "22.png",
"section": "三、核心业务页面",
"title": "保证金管理"
},
{
"file": "23.png",
"section": "三、核心业务页面",
"title": "供应商管理"
},
{
"file": "24.png",
"section": "三、核心业务页面",
"title": "专家管理"
},
{
"file": "25.png",
"section": "三、核心业务页面",
"title": "报表中心"
},
{
"file": "26.png",
"section": "三、核心业务页面",
"title": "异常决策"
},
{
"file": "27.png",
"section": "三、核心业务页面",
"title": "评标任务"
},
{
"file": "28.png",
"section": "三、核心业务页面",
"title": "投标中心"
},
{
"file": "29.png",
"section": "三、核心业务页面",
"title": "审计日志"
},
{
"file": "30.png",
"section": "三、核心业务页面",
"title": "围串标分析"
},
{
"file": "31.png",
"section": "三、核心业务页面",
"title": "存证验证"
},
{
"file": "32.png",
"section": "三、核心业务页面",
"title": "用户与权限管理"
},
{
"file": "33.png",
"section": "三、核心业务页面",
"title": "密钥管理"
},
{
"file": "34.png",
"section": "三、核心业务页面",
"title": "系统设置"
},
{
"file": "35.png",
"section": "四、弹窗与抽屉",
"title": "供应商详情抽屉"
},
{
"file": "36.png",
"section": "四、弹窗与抽屉",
"title": "评标纪律承诺书弹窗"
},
{
"file": "37.png",
"section": "四、弹窗与抽屉",
"title": "密钥漂移二次授权弹窗"
}
]