Selaa lähdekoodia

优化投续标指挥页标书制作:对接生成进度、完善上传生成门槛与刷新误回退

Co-authored-by: Cursor <cursoragent@cursor.com>
wdq 1 viikko sitten
vanhempi
sitoutus
97363135d5

+ 265 - 0
API进度查询接口对接文档.md

@@ -0,0 +1,265 @@
+# Proposa AI 任务进度查询接口对接文档
+
+版本日期:2026-09-09。适用范围:当前仓库 API 实现。
+
+本文中的响应为按实现整理的示例,时间、错误场景和业务 ID 均为示意,**不是对任务 `b429d8ddce344f2fadac91195aa56d97` 的线上查询结果**。服务地址请替换为实际部署地址。
+
+## 1. 接口信息
+
+| 项目 | 说明 |
+| --- | --- |
+| 用途 | 查询投标文件生成任务的 Step1–6 步骤进度 |
+| 方法 | `GET` |
+| 路径 | `/api/v1/jobs/{request_id}/progress` |
+| 本次任务路径 | `/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/progress` |
+| 路径参数 | `request_id`:提交任务后返回的任务 ID,字符串,必填;不是 `txbId` |
+| Query / Body | 无 |
+| 响应格式 | `application/json`,直接返回对象,没有 `code` / `data` 外层包装 |
+| 鉴权 | 当前应用代码未要求 token;若部署网关设置鉴权,按网关要求提供 |
+
+请求示例(将 `https://api.example.com` 替换为实际地址):
+
+```bash
+curl "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/progress" -H "Accept: application/json"
+```
+
+**对接核心规则:`/progress` 是步骤快照,最终成功或失败必须查询 `GET /api/v1/jobs/{request_id}` 的 `status`。HTTP 200 仅表示查询成功,不表示生成成功。**
+
+## 2. 进度响应字段
+
+有步骤快照时返回以下字段;无快照时仅返回 `status`、`message`,其余字段缺省。
+
+| 字段 | 类型 | 含义 |
+| --- | --- | --- |
+| `updated_at` | string | 快照最后更新时间,ISO 8601,UTC,如 `2026-09-09T02:30:00+00:00`;北京时间需加 8 小时 |
+| `current_step` | integer | 当前或最近完成的步骤编号,1–6 |
+| `total_steps` | integer | 总步骤数,当前固定为 6 |
+| `current_step_name` | string | 步骤名称 |
+| `status` | string | 有快照时为 `running` 或 `completed`;无快照时回退为任务状态,见后文 |
+| `message` | string | 展示提示语,不应作为程序判断条件 |
+| `completed_steps` | integer[] | 已执行完成的步骤编号;正在执行的步骤不包含在其中 |
+
+此接口不返回 `request_id`、`txbId`、`error`、`resultPath`、下载地址或 `progress_url`。调用方需保存提交时获得的任务 ID。
+
+| 步骤 | `current_step_name` |
+| --- | --- |
+| 1 | Step 1 文档解析及表/函/表格提取 |
+| 2 | Step 2 招标需求、评分项、废标项分析 |
+| 3 | Step 3 投书大纲生成 |
+| 4 | Step 4 章节内容生成与模板填充 |
+| 5 | Step 5 覆盖性、风险与完整性审核修复 |
+| 6 | Step 6 最终 DOCX 聚合装配 |
+
+## 3. 处理中拿到的结果
+
+### 3.1 尚未产生步骤快照
+
+HTTP 200,排队时可能返回:
+
+```json
+{
+  "status": "queued",
+  "message": "任务已排队或尚未开始"
+}
+```
+
+后台已启动但尚未写入有效快照时:
+
+```json
+{
+  "status": "processing",
+  "message": "任务已排队或尚未开始"
+}
+```
+
+调用方显示“排队中”或“处理中”并继续轮询;不要强制读取此时不存在的 `current_step`、`completed_steps`。
+
+### 3.2 正在执行某一步骤
+
+HTTP 200,例如正在执行 Step3:
+
+```json
+{
+  "updated_at": "2026-09-09T02:30:00+00:00",
+  "current_step": 3,
+  "total_steps": 6,
+  "current_step_name": "Step 3 投书大纲生成",
+  "status": "running",
+  "message": "Step 3 投书大纲生成 正在处理",
+  "completed_steps": [1, 2]
+}
+```
+
+含义:Step1、Step2 已完成,Step3 正在执行。仍需查询任务状态确认任务没有失败,原因见第 4 节。
+
+### 3.3 某一步骤刚完成,但任务仍在处理
+
+HTTP 200,例如 Step3 刚完成、Step4 尚未更新快照:
+
+```json
+{
+  "updated_at": "2026-09-09T02:35:00+00:00",
+  "current_step": 3,
+  "total_steps": 6,
+  "current_step_name": "Step 3 投书大纲生成",
+  "status": "completed",
+  "message": "Step 3 投书大纲生成 处理完成",
+  "completed_steps": [1, 2, 3]
+}
+```
+
+这里的 `completed` 仅表示 Step3 完成,**不能停止轮询或提示整个任务完成**。
+
+## 4. 处理失败拿到的结果
+
+### 4.1 已产生快照后失败:进度可能仍为 running
+
+当前实现失败时不会把步骤快照改写为 `failed`。例如 Step4 执行失败,`/progress` 仍可能返回 HTTP 200 和以下最后快照:
+
+```json
+{
+  "updated_at": "2026-09-09T02:40:00+00:00",
+  "current_step": 4,
+  "total_steps": 6,
+  "current_step_name": "Step 4 章节内容生成与模板填充",
+  "status": "running",
+  "message": "Step 4 章节内容生成与模板填充 正在处理",
+  "completed_steps": [1, 2, 3]
+}
+```
+
+此时查询任务状态接口:
+
+```bash
+curl "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97"
+```
+
+该接口 HTTP 200 响应中的关键字段如下(**仅摘录字段,不是完整响应**):
+
+```json
+{
+  "request_id": "b429d8ddce344f2fadac91195aa56d97",
+  "txbId": "TEST-TXB-001",
+  "status": "failed",
+  "error": "Step1-6 处理进程异常退出(退出码 1)",
+  "callback_delivered": true
+}
+```
+
+调用方以任务 `status=failed` 判定失败,停止轮询,展示 `error`,隐藏下载入口。失败原因可能是“处理超时”、进程异常退出或其他错误文本,不能仅匹配某个固定错误字符串。
+
+`callback_delivered=true` 只说明结果回调已按 HTTP 状态成功投递,不表示生成成功。回调失败也不改变生成结果;可查看任务的 `callback_error`。
+
+### 4.2 未产生有效快照就失败
+
+`/progress` 可返回 HTTP 200:
+
+```json
+{
+  "status": "failed",
+  "message": "任务已排队或尚未开始"
+}
+```
+
+此处 `message` 是统一兜底文案,并非真实失败原因。仍需从任务状态接口读取 `error`。
+
+### 4.3 全部步骤完成后,最终文件校验或发布失败
+
+此时 `/progress` 甚至可能仍是 Step6 的 `completed` 快照,而任务状态接口返回 `failed`。最终结果仍以任务状态为准。
+
+注意:失败回调里的 `resultPath="error: ..."` 属于回调协议;`/progress` 不返回该字段,任务状态查询也不自动构造该字段。任务查询中如有 `output_path`,它只是预定输出路径,不能据此判成功。
+
+## 5. 处理完成拿到的结果
+
+步骤全部执行完成后,`/progress` 返回 HTTP 200:
+
+```json
+{
+  "updated_at": "2026-09-09T03:20:00+00:00",
+  "current_step": 6,
+  "total_steps": 6,
+  "current_step_name": "Step 6 最终 DOCX 聚合装配",
+  "status": "completed",
+  "message": "Step 6 最终 DOCX 聚合装配 处理完成",
+  "completed_steps": [1, 2, 3, 4, 5, 6]
+}
+```
+
+再查询任务状态接口,成功时关键字段如下(**仅摘录字段,不是完整响应**;文件名及路径为虚构示例):
+
+```json
+{
+  "request_id": "b429d8ddce344f2fadac91195aa56d97",
+  "txbId": "TEST-TXB-001",
+  "status": "completed",
+  "output_path": "/srv/proposa/output/api_jobs/b429d8ddce344f2fadac91195aa56d97/示例招标文件.docx",
+  "final_review_path": "/srv/proposa/output/api_jobs/b429d8ddce344f2fadac91195aa56d97/示例招标文件.docx",
+  "final_review_url": "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/file",
+  "callback_delivered": true,
+  "callback_error": ""
+}
+```
+
+只有任务 `status=completed` 才展示“生成完成”和下载按钮。`final_review_path` / `output_path` 是服务器路径,浏览器不能直接打开;通过下载接口获取文件:
+
+```bash
+curl "https://api.example.com/api/v1/jobs/b429d8ddce344f2fadac91195aa56d97/file" --output "投标文件.docx"
+```
+
+下载成功:HTTP 200,响应为 DOCX 二进制,Content-Type 为 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,不是 JSON。
+
+Step6 完成后还有文件检查、发布和回调投递过程;当前实现等回调尝试结束才更新任务终态。因此短暂出现“进度 Step6 completed、任务 processing”时,应继续等待。回调最终失败仍可得到任务 `completed`,但 `callback_delivered=false`。
+
+## 6. 异常响应和任务保留
+
+| 请求 | HTTP 状态 | 含义与处理 |
+| --- | --- | --- |
+| `/progress`、任务查询、下载 | 404 | 任务不存在;核对任务 ID 和服务实例,不应当作生成失败原因 |
+| 下载 `/file` | 409 | 任务未完成,或最终文件不存在;不可按 DOCX 保存响应 |
+| 查询 | 网络异常 / 5xx | 本次查询失败,不等于任务生成失败;提示重试,避免直接把业务任务改为 failed |
+
+404 响应:
+
+```json
+{
+  "detail": "任务不存在"
+}
+```
+
+下载 409 响应:
+
+```json
+{
+  "detail": "任务尚未生成可下载文件"
+}
+```
+
+任务记录当前保存在 API 进程内存中,服务重启后旧 ID 可能返回 404,即使磁盘文件还在。多实例部署需确保查询访问持有该任务记录的实例。
+
+默认清理中间文件时,会先将末次步骤快照保存在任务内存记录中,再清理中间目录。因此同一进程内,处理中间文件清理后仍可查询末次快照;该快照不会因清理自动变成成功或失败状态。
+
+## 7. 推荐对接流程
+
+1. 提交任务后保存 `request_id`,与自己的业务 `txbId` 关联。
+2. 建议每 3–5 秒查询一次任务状态;当状态为 `queued` 或 `processing` 时,同时查询 `/progress` 更新步骤展示。这是调用建议,服务端没有强制该轮询间隔。
+3. 任务状态为 `failed`:停止轮询,展示 `error`,不提供下载。
+4. 任务状态为 `completed`:停止轮询,提供 `/file` 下载;下载仍需检查 HTTP 状态。
+5. 两个查询不是同一时刻的原子快照,发生矛盾时以任务终态优先;未知字段可以忽略,未知状态保留重试和异常提示。
+
+判定逻辑示意:
+
+```text
+job = GET /api/v1/jobs/{request_id}
+如果查询失败:处理 HTTP/网络异常
+否则如果 job.status == failed:展示 job.error,结束轮询
+否则如果 job.status == completed:展示完成及下载按钮,结束轮询
+否则:
+    progress = GET /api/v1/jobs/{request_id}/progress
+    若有步骤字段:显示当前步骤和已完成步骤
+    否则:显示排队中或处理中
+    等待后继续查询 job
+```
+
+接口没有提供百分比或预计剩余时间。可显示“已完成 2/6 步”;各步骤耗时不同,不宜将步骤比例当作实际耗时百分比。`updated_at` 仅在步骤开始或结束时更新,长时间不变化本身不能证明失败。
+
+对接验收至少覆盖:排队无步骤字段、步骤 running、中间步骤 completed、失败但进度仍 running、Step6 completed 但任务仍 processing、任务 completed 后下载,以及 404/409。

+ 20 - 3
README.md

@@ -174,7 +174,7 @@ sq_manage/
 | 一张图 | `/#/onemap` | Mapbox 地图展示物业项目空间分布,支持 GeoJSON 绘制 |
 | 一张图 | `/#/onemap` | Mapbox 地图展示物业项目空间分布,支持 GeoJSON 绘制 |
 | 项目台账 | `/#/projects` | 物业项目全生命周期管理,含详情页 |
 | 项目台账 | `/#/projects` | 物业项目全生命周期管理,含详情页 |
 | 市场信息 | `/#/market` | 市场情报 & 竞争对手信息 |
 | 市场信息 | `/#/market` | 市场情报 & 竞争对手信息 |
-| 续标管理 | `/#/renewal` | 投标准备阶段的续标工作流,含多类详情页 |
+| 续标管理 | `/#/renewal` | 投标准备阶段的续标工作流;核心指挥页 `/#/renewal-expire-detail/:id` 覆盖标书上传、生成与获取 |
 | 招标池 | `/#/tender-pool` | 招标公告、投标意向、开标监控 |
 | 招标池 | `/#/tender-pool` | 招标公告、投标意向、开标监控 |
 | 投标结果 | `/#/bid-result` | 投标结果录入与展示 |
 | 投标结果 | `/#/bid-result` | 投标结果录入与展示 |
 
 
@@ -198,7 +198,16 @@ sq_manage/
 |------|------|------|
 |------|------|------|
 | OAUTH | `CONFIG.OAUTH_URL` | 登录、修改密码 |
 | OAUTH | `CONFIG.OAUTH_URL` | 登录、修改密码 |
 | DMS | `CONFIG.DMS_URL` | 栏目查询、内容 CRUD、文件上传、审核 |
 | DMS | `CONFIG.DMS_URL` | 栏目查询、内容 CRUD、文件上传、审核 |
-| SQ | `CONFIG.SQ_URL` | 统计、历史合作方、招标生成 |
+| SQ | `CONFIG.SQ_URL` | 统计、历史合作方、招标生成、标书生成进度查询 |
+
+常用 SQ 能力说明:
+
+| 接口方法 | 路径 | 说明 |
+|----------|------|------|
+| `getGenerateTender` | `POST /tender/generate` | 发起 AI 标书生成(投续标 ID) |
+| `getTenderProgress` | `GET /tender/api/v1/jobs/{txbId}/progress` | 查询标书生成步骤进度 |
+
+对接说明见仓库根目录 `API进度查询接口对接文档.md`。
 
 
 所有请求走 `/api` 代理前缀,开发时由 Vite 转发到后端。
 所有请求走 `/api` 代理前缀,开发时由 Vite 转发到后端。
 
 
@@ -267,4 +276,12 @@ Internal use only.
 
 
 - **2023-09-01**
 - **2023-09-01**
 1、 增加AI Agent 开发,项目已集成 Harness 工程文件
 1、 增加AI Agent 开发,项目已集成 Harness 工程文件
-2、 修改项目布局和Logo图标
+2、 修改项目布局和Logo图标
+
+- **2026-09-10**
+1、 优化投续标指挥页「标书制作」:任务4完成后可上传资料;行内「生成标书」+ 招标/采购/参考/澄清四类上传
+2、 任务5更名为「招标公告与需求拆解」;待确认前须具备招标文件、采购需求与有效参考标书(docx)
+3、 对接标书生成进度查询接口 `getTenderProgress`,获取标书按 `c_ai_sczt`(完成/失败/生成中)分流处理
+4、 生成中刷新页面不再误回退到「生成标书」;失败仅在主动「获取标书」或进度失败时回退并清空状态
+5、 续标创建时,在管项目历史标书为 docx 则写入投续标参考标书字段 `c_ckbs`
+6、 修复投标结果页组件导入路径大小写,保证 Windows 下 `vue-tsc` 构建通过

+ 1 - 1
public/static/config/config.js

@@ -17,7 +17,7 @@ window.CONFIG = {
     { id: 2, seq:2, type: 1, text: '现场踏勘与服务评估', done: false,create_time:'',username:'',userid:''},
     { id: 2, seq:2, type: 1, text: '现场踏勘与服务评估', done: false,create_time:'',username:'',userid:''},
     { id: 3, seq:3, type: 1, text: '关注招标公告发布(监控中)', done: false,create_time:'',username:'',userid:''},
     { id: 3, seq:3, type: 1, text: '关注招标公告发布(监控中)', done: false,create_time:'',username:'',userid:''},
     { id: 4, seq:4, type: 1, text: '报名并获取招标公告', done: false,create_time:'',username:'',userid:''},
     { id: 4, seq:4, type: 1, text: '报名并获取招标公告', done: false,create_time:'',username:'',userid:''},
-    { id: 5, seq:4, type: 1, text: '标书需求拆解与生成', done: false,create_time:'',username:'',userid:''},
+    { id: 5, seq:4, type: 1, text: '招标公告与需求拆解', done: false,create_time:'',username:'',userid:''},
     { id: 6, seq:5, type: 1, text: '登记投标信息', done: false,create_time:'',username:'',userid:''},
     { id: 6, seq:5, type: 1, text: '登记投标信息', done: false,create_time:'',username:'',userid:''},
     { id: 7, seq:6, type: 1, text: '开标标书监控', done: false,create_time:'',username:'',userid:''},
     { id: 7, seq:6, type: 1, text: '开标标书监控', done: false,create_time:'',username:'',userid:''},
     { id: 8, seq:7, type: 1, text: '中标确认与部门推送', done: false,create_time:'',username:'',userid:''},
     { id: 8, seq:7, type: 1, text: '中标确认与部门推送', done: false,create_time:'',username:'',userid:''},

+ 9 - 1
src/api/common.ts

@@ -122,6 +122,13 @@ const getGenerateTender = (params: any) => {
   return axios.post(CONFIG.SQ_URL + '/tender/generate', qs.stringify(params))
   return axios.post(CONFIG.SQ_URL + '/tender/generate', qs.stringify(params))
 }
 }
 
 
+/** 查询标书生成步骤进度;path 参数为投续标内容 ID */
+const getTenderProgress = (txbId: string | number) => {
+  return axios.get(`${CONFIG.SQ_URL}/tender/api/v1/jobs/${txbId}/progress`, {
+    headers: { Accept: 'application/json' },
+  })
+}
+
 
 
 export default {
 export default {
   login,
   login,
@@ -136,5 +143,6 @@ export default {
   uploadFile,
   uploadFile,
   getBasicCount,
   getBasicCount,
   getHistoryPartner,
   getHistoryPartner,
-  getGenerateTender
+  getGenerateTender,
+  getTenderProgress
 }
 }

+ 9 - 0
src/components/renewal/BidRenewal.vue

@@ -301,6 +301,7 @@ const addForm = ref({
   c_glqy: '',
   c_glqy: '',
   c_is_xb: false,
   c_is_xb: false,
   c_sfwc: false,
   c_sfwc: false,
+  c_ckbs: '',
   rwlcqd:JSON.stringify(CONFIG.TASKLIST)
   rwlcqd:JSON.stringify(CONFIG.TASKLIST)
 })
 })
 const renewalStats = ref([
 const renewalStats = ref([
@@ -766,6 +767,14 @@ const handleCascaderChange = (val: any) => {
   addForm.value.c_dqsj = moment(tempObj.c_zgxmhtdqrq as any).format('YYYY-MM-DD HH:mm').toString()
   addForm.value.c_dqsj = moment(tempObj.c_zgxmhtdqrq as any).format('YYYY-MM-DD HH:mm').toString()
   addForm.value.c_xmbt = tempObj.c_xmmc
   addForm.value.c_xmbt = tempObj.c_xmmc
   addForm.value.xmbh = tempObj.c_father_code?tempObj.c_father_code:tempObj.c_guid
   addForm.value.xmbh = tempObj.c_father_code?tempObj.c_father_code:tempObj.c_guid
+  // 续标:历史标书须以 .docx 结尾才写入 c_ckbs;否则置空,生成前必须上传
+  const historyTender = tempObj.c_history_tender || ''
+  if (addForm.value.c_is_xb && typeof historyTender === 'string') {
+    const clean = historyTender.toLowerCase().replace(/\?.*$/, '').trim()
+    addForm.value.c_ckbs = clean.endsWith('.docx') ? historyTender : ''
+  } else {
+    addForm.value.c_ckbs = ''
+  }
   // addForm.value.c_is_xb = false
   // addForm.value.c_is_xb = false
   addForm.value.rwlcqd = JSON.stringify(CONFIG.TASKLIST)
   addForm.value.rwlcqd = JSON.stringify(CONFIG.TASKLIST)
   
   

+ 638 - 136
src/views/bidPreparation/RenewalExpireDetail.vue

@@ -45,49 +45,48 @@
             </p>
             </p>
             <div class="monitoring-actions" v-if="(steps.filter(item => item.n === stage)[0] || {}).left_btn_text">
             <div class="monitoring-actions" v-if="(steps.filter(item => item.n === stage)[0] || {}).left_btn_text">
               <div v-if="(steps.filter(item => item.n === stage)[0] || {}).label == '标书制作'" style="width: 100%;">
               <div v-if="(steps.filter(item => item.n === stage)[0] || {}).label == '标书制作'" style="width: 100%;">
-                <div v-if="stepsType == '1'" style="display: inline-block;">
-                  <el-button type="primary" class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'')">
-                    提交资料
-                  </el-button>
-                </div>
-                <div v-if="stepsType == '2'" style="display: inline-block;">
-                  <el-button type="primary"  class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'')">
-                    生成标书
-                  </el-button>
-                  <el-button type="primary" v-if="!bidFile.isFileFlag" class="btn enter-btn" @click="dialogFormBidFileVisible = true">
-                    上传参考标书
-                  </el-button>
-                  <!-- <el-button type="primary" :loading="swb_status == '2'" class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'swb')">
-                    生成商务标书
-                  </el-button>
-                  <el-button type="primary" :loading="jsb_status == '2'" class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'jsb')">
-                    生成技术标书
-                  </el-button> -->
+                <!-- 招标公告与需求拆解:报名确认后可上传;待确认后可生成 -->
+                <div v-if="stepsType == '1' || stepsType == '2'" class="bid-make-actions">
+                  <div class="bid-make-left">
+                    <el-button type="primary" class="btn enter-btn" :loading="generateLoading" :disabled="!canShowBidMakeActions" @click="handleGenerateBid">
+                      生成标书
+                    </el-button>
+                    <span v-if="!canShowBidMakeActions" class="bid-make-tip">请先完成「报名并获取招标公告」</span>
+                    <span v-else-if="!canGenerateBid" class="bid-make-tip">请先确认任务清单中的「招标公告与需求拆解」</span>
+                  </div>
+                  <div v-if="canShowBidMakeActions" class="bid-make-right">
+                    <el-button type="primary" class="btn" :loading="uploadLoading.zbwj" @click="triggerUpload('zbwj')">
+                      {{ projects.c_zbwj ? '修改招标文件' : '上传招标文件' }}
+                    </el-button>
+                    <el-button type="primary" class="btn" :loading="uploadLoading.cgxqwj" @click="triggerUpload('cgxqwj')">
+                      {{ projects.c_cgxqwj ? '修改采购需求文件' : '上传采购需求文件' }}
+                    </el-button>
+                    <el-button type="primary" class="btn" :loading="uploadLoading.ckbs" @click="triggerUpload('ckbs')">
+                      {{ hasRefBidFile ? '修改参考标书' : '上传参考标书' }}
+                    </el-button>
+                    <el-button type="primary" class="btn" :loading="uploadLoading.cqgg" @click="triggerUpload('cqgg')">
+                      {{ projects.c_cqggwj ? '修改澄清公告' : '上传澄清公告' }}
+                    </el-button>
+                  </div>
+                  <input ref="uploadInputZbwj" type="file" accept=".pdf" style="display:none" @change="onUploadFileChange('zbwj', $event)" />
+                  <input ref="uploadInputCgxq" type="file" accept=".docx,.pdf" style="display:none" @change="onUploadFileChange('cgxqwj', $event)" />
+                  <input ref="uploadInputCkbs" type="file" accept=".docx" style="display:none" @change="onUploadFileChange('ckbs', $event)" />
+                  <input ref="uploadInputCqgg" type="file" accept=".docx,.pdf" style="display:none" @change="onUploadFileChange('cqgg', $event)" />
                 </div>
                 </div>
                 <div v-if="stepsType == '3'" style="display: inline-block;">
                 <div v-if="stepsType == '3'" style="display: inline-block;">
-                  <el-button type="primary" :loading="stepsType == '3'" class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'')">
+                  <el-button type="primary" :loading="true" class="btn enter-btn">
                     生成标书
                     生成标书
                   </el-button>
                   </el-button>
                 </div>
                 </div>
-                <div v-if="stepsType == '4'" style="display: inline-block;">
-                  <el-button type="primary" :disabled="!bidFile.flag" class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'')">
+                <div v-if="stepsType == '4'" style="display: inline-block; width: 100%;">
+                  <el-button type="primary" :disabled="!canEnterBidSubmit" class="btn enter-btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'')">
                     进入投标提交
                     进入投标提交
                   </el-button>
                   </el-button>
-                  <el-button type="primary" class="btn" @click="getBidFile()">
+                  <el-button type="primary" class="btn" :loading="getBidLoading" @click="getBidFile()">
                     获取标书
                     获取标书
                   </el-button>
                   </el-button>
-                  <!-- <el-button type="primary" class="btn" @click="getBidFile('swb')">
-                    获取商务标书
-                  </el-button>
-                  <el-button type="primary" class="btn" @click="getBidFile('jsb')">
-                    获取技术标书
-                  </el-button> -->
+                  <div v-if="generateProgressTip" class="bid-progress-tip">{{ generateProgressTip }}</div>
                 </div>
                 </div>
-                <!-- <div style="float: right;">
-                  <el-button type="primary"  class="btn" @click="handleViewClick(steps.filter(item => item.n === stage)[0] || {},'cqgg')">
-                    上传澄清公告
-                  </el-button>
-                </div> -->
               </div>
               </div>
               <div v-else-if="(steps.filter(item => item.n === stage)[0] || {}).label == '招标监控'" style="width: 100%;">
               <div v-else-if="(steps.filter(item => item.n === stage)[0] || {}).label == '招标监控'" style="width: 100%;">
                 <!-- <div v-if="stepsType == '1'" style="display: inline-block;">
                 <!-- <div v-if="stepsType == '1'" style="display: inline-block;">
@@ -114,6 +113,15 @@
                     进入中标推送
                     进入中标推送
                   </el-button>
                   </el-button>
               </div>
               </div>
+              <!-- 投标提交:登记投标;生成完成后、开标监控创建前可下载标书 -->
+              <div v-else-if="(steps.filter(item => item.n === stage)[0] || {}).label == '投标提交'" style="width: 100%;">
+                <el-button type="primary" class="btn enter-btn" @click="handleViewClick((steps.filter(item => item.n === stage)[0] || {}),'')" :disabled="left_btn_disabled" :loading="left_btn_disabled">
+                  {{(steps.filter(item => item.n === stage)[0] || {}).left_btn_text || '登记投标'}}
+                </el-button>
+                <el-button v-if="showDownloadBidAfterSubmit" type="primary" class="btn" :loading="downloadBidLoading" @click="downloadCurrentBidFile">
+                  下载标书
+                </el-button>
+              </div>
               <div v-else>
               <div v-else>
                 <el-button type="primary" class="btn enter-btn" @click="handleViewClick((steps.filter(item => item.n === stage)[0] || {}),'')" :disabled="left_btn_disabled" :loading="left_btn_disabled">
                 <el-button type="primary" class="btn enter-btn" @click="handleViewClick((steps.filter(item => item.n === stage)[0] || {}),'')" :disabled="left_btn_disabled" :loading="left_btn_disabled">
                   {{(steps.filter(item => item.n === stage)[0] || {}).left_btn_text || ''}}
                   {{(steps.filter(item => item.n === stage)[0] || {}).left_btn_text || ''}}
@@ -134,7 +142,7 @@
           <div class="card-body">
           <div class="card-body">
             <ul class="checklist">
             <ul class="checklist">
               <li :class="['checklist-item']" v-for="task in projects.rwlcqd" :key="task.id">
               <li :class="['checklist-item']" v-for="task in projects.rwlcqd" :key="task.id">
-                <span :class="['checklist-status', task.done ? 'done' : '']">{{task.done ? '✓' : '○'}} &nbsp;&nbsp; {{task.text}}</span>
+                <span :class="['checklist-status', task.done ? 'done' : '']">{{task.done ? '✓' : '○'}} &nbsp;&nbsp; {{getTaskDisplayText(task)}}</span>
                 <!-- <span class="checklist-text">{{task.text}}</span> -->
                 <!-- <span class="checklist-text">{{task.text}}</span> -->
                  <strong style="cursor: pointer;color: var(--primary);" v-if="task.done && projects.zbmsxx?.zb_detail_url && task.id == 4" @click="handleOpenViewClick('1')">查看招标公告</strong>
                  <strong style="cursor: pointer;color: var(--primary);" v-if="task.done && projects.zbmsxx?.zb_detail_url && task.id == 4" @click="handleOpenViewClick('1')">查看招标公告</strong>
                 <div class="checklist-actions" v-if="task.seq<=stage">
                 <div class="checklist-actions" v-if="task.seq<=stage">
@@ -145,14 +153,20 @@
                     <span v-if="task.done">匹配成功</span>
                     <span v-if="task.done">匹配成功</span>
                     <span v-else @click="confirmTask(task)">{{task.type == 1 ? '匹配中' : task.type == 2 ? '待确认' :''}}</span>
                     <span v-else @click="confirmTask(task)">{{task.type == 1 ? '匹配中' : task.type == 2 ? '待确认' :''}}</span>
                   </el-button>
                   </el-button>
-                  <el-button type="primary" size="small" :disabled="task.done" :plain="task.done"  v-if="[7].includes(task.id) && !task.done">
+                  <el-button type="primary" size="small" :disabled="task.done || finishBidLoading" :plain="task.done" :loading="finishBidLoading" v-if="[7].includes(task.id) && !task.done">
                     <span v-if="!task.done" @click="confirmTask(task)">人工处理</span>
                     <span v-if="!task.done" @click="confirmTask(task)">人工处理</span>
                   </el-button>
                   </el-button>
-                  <el-button type="primary" size="small" :disabled="task.done" :plain="task.done" v-if="task.id == 7" :loading="task.type == 1">
+                  <el-button type="primary" size="small" :disabled="task.done || finishBidLoading" :plain="task.done" v-if="task.id == 7" :loading="task.type == 1 || finishBidLoading">
                     <span v-if="task.done">已完成</span>
                     <span v-if="task.done">已完成</span>
                     <span v-else @click="confirmTask(task)">{{task.type == 1 ? '监控中' : task.type == 2 ? '待确认' :''}}</span>
                     <span v-else @click="confirmTask(task)">{{task.type == 1 ? '监控中' : task.type == 2 ? '待确认' :''}}</span>
                   </el-button>
                   </el-button>
-                  <el-button type="primary" size="small" :disabled="task.done" :plain="task.done" v-if="task.id != 3 && task.id != 7">
+                  <!-- 任务5:须先完成报名(任务4) -->
+                  <el-button type="primary" size="small" :disabled="task.done || !isTaskDone(4)" :plain="task.done" v-if="task.id == 5">
+                    <span v-if="task.done">已完成</span>
+                    <span v-else-if="!isTaskDone(4)">请先报名</span>
+                    <span v-else @click="confirmTask(task)">待确认</span>
+                  </el-button>
+                  <el-button type="primary" size="small" :disabled="task.done" :plain="task.done" v-if="task.id != 3 && task.id != 7 && task.id != 5">
                     <span v-if="task.done">已完成</span>
                     <span v-if="task.done">已完成</span>
                     <span v-else @click="confirmTask(task)">待确认</span>
                     <span v-else @click="confirmTask(task)">待确认</span>
                   </el-button>
                   </el-button>
@@ -313,7 +327,7 @@
       </div>
       </div>
     </div>
     </div>
 
 
-    <el-dialog v-model="dialogKBJKVisible" draggable title="提示信息" width="500">
+    <el-dialog v-model="dialogKBJKVisible" draggable title="提示信息" width="500" :close-on-click-modal="false">
         <div style="height: auto;overflow: auto;">
         <div style="height: auto;overflow: auto;">
           <el-form
           <el-form
             ref="ruleKBJKFormRef"
             ref="ruleKBJKFormRef"
@@ -404,7 +418,7 @@
         </template>
         </template>
       </el-dialog> -->
       </el-dialog> -->
 
 
-      <el-dialog v-model="dialogZBJKVisible" draggable title="提示信息" width="500">
+      <el-dialog v-model="dialogZBJKVisible" draggable title="提示信息" width="500" :close-on-click-modal="false">
         <div style="height: auto;overflow: auto;">
         <div style="height: auto;overflow: auto;">
           <el-select v-model="ruleZBJKForm.c_project_jkname" placeholder="请选择匹配的项目"  value-key="value" @change="handleJKTypeChange" >
           <el-select v-model="ruleZBJKForm.c_project_jkname" placeholder="请选择匹配的项目"  value-key="value" @change="handleJKTypeChange" >
               <el-option v-for="item in zbjkProject" :key="item.id" :label="item.name" :value="item.id" />
               <el-option v-for="item in zbjkProject" :key="item.id" :label="item.name" :value="item.id" />
@@ -421,7 +435,7 @@
         </template>
         </template>
       </el-dialog>
       </el-dialog>
 
 
-    <el-dialog v-model="dialogZBVisible" draggable title="提示信息" width="500">
+    <el-dialog v-model="dialogZBVisible" draggable title="提示信息" width="500" :close-on-click-modal="false">
         <div style="overflow: auto;">
         <div style="overflow: auto;">
           <el-form
           <el-form
             ref="ruleZBFormRef"
             ref="ruleZBFormRef"
@@ -482,7 +496,7 @@
         </template>
         </template>
       </el-dialog>
       </el-dialog>
 
 
-      <el-dialog v-model="dialogBidVisible" draggable title="提示信息" width="500">
+      <el-dialog v-model="dialogBidVisible" draggable title="提示信息" width="500" :close-on-click-modal="false">
         <div style="height: 500px;overflow: auto;">
         <div style="height: 500px;overflow: auto;">
           <el-form
           <el-form
             ref="ruleBidFormRef"
             ref="ruleBidFormRef"
@@ -579,7 +593,7 @@
         </template>
         </template>
       </el-dialog>
       </el-dialog>
 
 
-      <el-dialog v-model="dialogFinishBidVisible" draggable title="提示信息" width="350">
+      <el-dialog v-model="dialogFinishBidVisible" draggable title="提示信息" width="350" :close-on-click-modal="false">
         <div style="margin-bottom: 5px;">
         <div style="margin-bottom: 5px;">
           <el-switch v-model="finishBidObjectTemp.flag" inline-prompt active-text="从中标结果中选择" inactive-text="手动输入" style="background-color: unset;" @change="handleSwitchChange"/>
           <el-switch v-model="finishBidObjectTemp.flag" inline-prompt active-text="从中标结果中选择" inactive-text="手动输入" style="background-color: unset;" @change="handleSwitchChange"/>
         </div>
         </div>
@@ -605,7 +619,7 @@
         </template>
         </template>
       </el-dialog>
       </el-dialog>
 
 
-      <el-dialog v-model="dialogFinishBidVisible2" draggable title="提示信息" width="350">
+      <el-dialog v-model="dialogFinishBidVisible2" draggable title="提示信息" width="350" :close-on-click-modal="false">
         <div v-if="finishBidObject?.c_detail_url" style="padding-top: 20px;text-align: center;color: var(--primary);">
         <div v-if="finishBidObject?.c_detail_url" style="padding-top: 20px;text-align: center;color: var(--primary);">
           <strong  style="cursor: pointer;" @click="handleOpenViewClick('0')">点击查看招标结果文件</strong>
           <strong  style="cursor: pointer;" @click="handleOpenViewClick('0')">点击查看招标结果文件</strong>
         </div>
         </div>
@@ -620,7 +634,7 @@
         </template>
         </template>
       </el-dialog>
       </el-dialog>
 
 
-      <el-dialog v-model="dialogBidFileVisible" draggable title="提示信息" width="300">
+      <el-dialog v-model="dialogBidFileVisible" draggable title="提示信息" width="300" :close-on-click-modal="false">
         <div>
         <div>
           <strong style="cursor: pointer;" @click="handleOpenViewClick('1')">点击查看招标公告</strong>
           <strong style="cursor: pointer;" @click="handleOpenViewClick('1')">点击查看招标公告</strong>
         </div>
         </div>
@@ -635,7 +649,7 @@
         </template>
         </template>
       </el-dialog>
       </el-dialog>
 
 
-      <el-dialog v-model="dialogFormFileVisible" draggable title="提示信息" width="300">
+      <el-dialog v-model="dialogFormFileVisible" draggable title="提示信息" width="300" :close-on-click-modal="false">
         <el-input v-model="FileForm.name" autocomplete="off" placeholder="请输入文件名称"/>
         <el-input v-model="FileForm.name" autocomplete="off" placeholder="请输入文件名称"/>
         <div style="margin-top: 10px;">
         <div style="margin-top: 10px;">
         <el-upload
         <el-upload
@@ -665,7 +679,7 @@
        </el-dialog>
        </el-dialog>
 
 
 
 
-       <el-dialog v-model="dialogFormBidFileVisible" draggable title="提示信息" width="300">
+       <el-dialog v-model="dialogFormBidFileVisible" draggable title="提示信息" width="300" :close-on-click-modal="false">
         <!-- <el-input v-model="FileForm.name" autocomplete="off" placeholder="请输入文件名称"/> -->
         <!-- <el-input v-model="FileForm.name" autocomplete="off" placeholder="请输入文件名称"/> -->
         <div style="margin-top: 10px;">
         <div style="margin-top: 10px;">
         <el-upload
         <el-upload
@@ -698,7 +712,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref,reactive, onMounted } from 'vue'
+import { ref,reactive, onMounted, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 const router = useRouter()
 const router = useRouter()
 import moment from 'moment'
 import moment from 'moment'
@@ -751,6 +765,83 @@ const steps = ref<any[]>([]);
 const stage = ref(Number(sessionStorage.getItem('progres')) || 0);
 const stage = ref(Number(sessionStorage.getItem('progres')) || 0);
 
 
 const stepsType = ref('')
 const stepsType = ref('')
+const generateLoading = ref(false)
+const uploadLoading = reactive({
+  zbwj: false,
+  cgxqwj: false,
+  ckbs: false,
+  cqgg: false,
+})
+const uploadInputZbwj = ref<HTMLInputElement | null>(null)
+const uploadInputCgxq = ref<HTMLInputElement | null>(null)
+const uploadInputCkbs = ref<HTMLInputElement | null>(null)
+const uploadInputCqgg = ref<HTMLInputElement | null>(null)
+
+/** 是否为可用的 docx 路径(去 query 后须以 .docx 结尾) */
+const isDocxPath = (path: any) => {
+  if (!path || typeof path !== 'string') return false
+  const clean = path.toLowerCase().replace(/\?.*$/, '').trim()
+  return clean.endsWith('.docx')
+}
+
+/** 投续标表是否已有可用参考标书(字段 c_ckbs) */
+const hasRefBidFile = computed(() => isDocxPath(projects.value?.c_ckbs))
+
+/** 报名任务(id=4)是否完成,完成后才进入标书拆解/上传 */
+const isTaskDone = (id: number | string) => {
+  const list = projects.value?.rwlcqd
+  if (!Array.isArray(list)) return false
+  const task = list.find((p: any) => p.id == id)
+  return !!task?.done
+}
+
+const canShowBidMakeActions = computed(() => isTaskDone(4))
+/** 须先点任务5「待确认」,才可生成标书 */
+const canGenerateBid = computed(() => isTaskDone(4) && isTaskDone(5))
+
+/** 任务展示名(兼容历史数据旧文案) */
+const getTaskDisplayText = (task: any) => {
+  if (task?.id == 5 || task?.id == '5') return '招标公告与需求拆解'
+  return task?.text || ''
+}
+
+/** 待确认「招标公告与需求拆解」前校验必要文件 */
+const validateBidMakeFilesForConfirm = () => {
+  if (!projects.value?.c_zbwj) {
+    ElMessage.error('请先上传招标文件')
+    return false
+  }
+  if (!projects.value?.c_cgxqwj) {
+    ElMessage.error('请先上传采购需求文件')
+    return false
+  }
+  if (!hasRefBidFile.value) {
+    if (projects.value?.c_is_xb === true) {
+      ElMessage.error('续标项目未找到参考标书,请上传参考标书(.docx)')
+    } else {
+      ElMessage.error('请先上传参考标书(.docx)')
+    }
+    return false
+  }
+  return true
+}
+
+/** 仅当生成完成且已成功下载标书后,才可进入投标提交 */
+const canEnterBidSubmit = computed(() => {
+  const sczt = String(projects.value?.c_ai_sczt ?? '').trim()
+  return sczt === '完成' && !!bidFile.value.flag
+})
+/**
+ * 进入投标提交后、开标监控创建前:显示「下载标书」
+ * 开标监控在登记投标时创建,成功后投续标写入 c_xgjk
+ */
+const showDownloadBidAfterSubmit = computed(() => {
+  const sczt = String(projects.value?.c_ai_sczt ?? '').trim()
+  const hasKbjk = !!(projects.value?.c_xgjk)
+  return Number(stage.value) === 5 && sczt === '完成' && !hasKbjk
+})
+const downloadBidLoading = ref(false)
+const finishBidLoading = ref(false)
 // const swb_status = ref('1')
 // const swb_status = ref('1')
 // const jsb_status = ref('1')
 // const jsb_status = ref('1')
 
 
@@ -993,14 +1084,29 @@ const confirmTask = async (task: any) => {
     const msg = getMessage(4)
     const msg = getMessage(4)
     if(msg){
     if(msg){
       ElMessage.error(msg)
       ElMessage.error(msg)
+    }else if(!isTaskDone(4)){
+      ElMessage.error("请先完成「报名并获取招标公告」后,再进行「招标公告与需求拆解」")
+    }else if(!validateBidMakeFilesForConfirm()){
+      // 缺少招标文件/采购需求/参考标书时已提示
     }else{
     }else{
+      // 同步任务展示名为新文案后写入
+      const t = projects.value.rwlcqd?.find((p: any) => p.id == 5 || p.id == '5')
+      if (t) t.text = '招标公告与需求拆解'
       updaterwlcqdProject(task)
       updaterwlcqdProject(task)
     }
     }
   }else if(task.id == "6"){
   }else if(task.id == "6"){
     updaterwlcqdProject(task)
     updaterwlcqdProject(task)
   }else if(task.id == "7"){
   }else if(task.id == "7"){
-    const res = await getfinishProjects()
-    if(res){
+    if (finishBidLoading.value) return
+    finishBidLoading.value = true
+    const loading = ElLoading.service({
+      lock: true,
+      text: '加载中标结果数据中,请稍候...',
+      background: 'rgba(0, 0, 0, 0.7)',
+    })
+    try {
+      const res = await getfinishProjects()
+      if(res){
         finishBidOption.value = res
         finishBidOption.value = res
         if(task.type != 2){
         if(task.type != 2){
           dialogFinishBidVisible.value = true
           dialogFinishBidVisible.value = true
@@ -1011,7 +1117,14 @@ const confirmTask = async (task: any) => {
         }
         }
       }else{
       }else{
         finishBidOption.value = []
         finishBidOption.value = []
+        ElMessage.warning('暂无中标结果数据')
       }
       }
+    } catch (e: any) {
+      ElMessage.error(e?.message || '加载中标结果失败,请稍后重试')
+    } finally {
+      finishBidLoading.value = false
+      loading.close()
+    }
   }else if(task.id == "8"){
   }else if(task.id == "8"){
     sessionStorage.setItem('activeTab','won')
     sessionStorage.setItem('activeTab','won')
     sessionStorage.setItem('jkId', projects.value.c_xgjk);
     sessionStorage.setItem('jkId', projects.value.c_xgjk);
@@ -1063,50 +1176,12 @@ const handleViewClick = (item: any,type: string) => {
       }
       }
       
       
     }else if(item.n == 4){
     }else if(item.n == 4){
-      if(type == 'cqgg'){
-          dialogFormFileVisible.value = true
-      }else if(stepsType.value == '1'){
-        let flag =  projects.value.rwlcqd.find((p: any) => p.id == '4').done
-        if(flag){
-          dialogBidVisible.value = true
-        }else{
-          ElMessage.error("请先报名获取招标文件后,可进行提交资料")
-        }
-        
-      }else if(stepsType.value == '2'){
-        let flag =  projects.value.rwlcqd.find((p: any) => p.id == '5').done
-        if(flag){
-          if(soureProjects.value.c_history_tender){// 有历史标书
-            if(soureProjects.value.c_history_tender.indexOf(".docx") != -1){// 历史标书是正常的docx文件
-              // 生成招标文件
-              api.getGenerateTender({txbId: projects.value.id}).then((res: any) => {
-                if ((res as any).code === 200) {
-                  updateTypeProject(3)
-                  ElMessage.success("标书文件生成成功")
-                  bidFile.value.isFileFlag = false
-                  sessionStorage.setItem('isFileFlag', 'false')
-                }else{
-                  ElMessage.error((res as any).content || "标书文件生成失败")
-                }
-              })
-
-            }else{
-              ElMessage.error("请先确认上传参考标书")
-              bidFile.value.isFileFlag = false
-              sessionStorage.setItem('isFileFlag', 'false')
-            }
-          }else{
-            // 无历史标书
-            ElMessage.error("请先确认上传参考标书")
-            bidFile.value.isFileFlag = false
-            sessionStorage.setItem('isFileFlag', 'false')
-          }
-          
-          
-        }else{
-          ElMessage.error("请先确认标书需求拆解与生成")
+      // 标书制作:stepsType 1/2 由页面「生成标书」/上传按钮处理;步骤4 仅进入投标提交
+      if(stepsType.value == '4'){
+        if (!canEnterBidSubmit.value) {
+          ElMessage.error('请先获取并下载标书后再进入投标提交')
+          return
         }
         }
-      }else if(stepsType.value == '4'){
         updateProject()
         updateProject()
       }
       }
     }else if(item.n == 5){
     }else if(item.n == 5){
@@ -1250,6 +1325,12 @@ const getProjects = async () => {
       money: soureProjects.value.c_nhhtsr,
       money: soureProjects.value.c_nhhtsr,
       address: soureProjects.value.c_query_address || ''
       address: soureProjects.value.c_query_address || ''
     }]
     }]
+    // 续标:投续标参考标书为空时,尝试用在管项目历史标书(仅 .docx)回填
+    await syncRefBidFromSourceProject()
+    // 未生成完成时不允许沿用旧的「已下载」状态进入投标提交
+    syncBidDownloadFlagByAiStatus()
+    // 处于「获取标书」时,进入页面先拉取并展示生成步骤
+    await refreshBidProgressTipOnEnter()
   }
   }
   
   
   if(projects.value.rwlcqd[2].done == false){//匹配招标监控
   if(projects.value.rwlcqd[2].done == false){//匹配招标监控
@@ -1678,11 +1759,11 @@ const handleBidFileChange = (file: any) => {
 
 
 const submitBidFileForm = () => {
 const submitBidFileForm = () => {
     console.log(BidFileForm.value)
     console.log(BidFileForm.value)
-    const columnId = CONFIG.COLUMNIDARR[0]
+    // 参考标书写入投续标表字段 c_ckbs(modelId 1852)
     const params: any = {
     const params: any = {
-      columnId: columnId,
-      contentId: soureProjects.value.id,
-      paraName: 'c_history_tender',
+      columnId: CONFIG.COLUMNIDARR[2],
+      contentId: projects.value.id,
+      paraName: 'c_ckbs',
       type: 3,
       type: 3,
       file: BidFileForm.value.file.raw
       file: BidFileForm.value.file.raw
     }
     }
@@ -1690,15 +1771,16 @@ const submitBidFileForm = () => {
       if((res as any).code === 200){
       if((res as any).code === 200){
         const content = res.content
         const content = res.content
         const params: any = {
         const params: any = {
-            columnId: CONFIG.COLUMNIDARR[0],
-            modelId: CONFIG.MODELIDARR[7],
+            columnId: CONFIG.COLUMNIDARR[2],
+            modelId: CONFIG.MODELIDARR[0],
             content: JSON.stringify({
             content: JSON.stringify({
-              id: soureProjects.value.id,
-              c_history_tender: content,
+              id: projects.value.id,
+              c_ckbs: content,
             }),
             }),
           }
           }
          api.updateContent(params).then((res: any) => {
          api.updateContent(params).then((res: any) => {
           if((res as any).code === 200){
           if((res as any).code === 200){
+            projects.value.c_ckbs = content
             dialogFormBidFileVisible.value = false
             dialogFormBidFileVisible.value = false
             bidFile.value.isFileFlag = true
             bidFile.value.isFileFlag = true
             sessionStorage.setItem('isFileFlag', 'true')
             sessionStorage.setItem('isFileFlag', 'true')
@@ -1818,17 +1900,21 @@ const handleFileChange3 = (file: any) => {
 //   filename.value = file.raw.name
 //   filename.value = file.raw.name
 //   ruleBidForm.file3.file = fileList
 //   ruleBidForm.file3.file = fileList
 // }
 // }
-// 更新项目进度状态
-const updateTypeProject = async (type:any) => {
+// 更新项目进度状态;extra 可附带清空 c_ai_sczt 等字段
+const updateTypeProject = async (type:any, extra: Record<string, any> = {}) => {
   let xmjd_json = projects.value.c_xmjd_json;
   let xmjd_json = projects.value.c_xmjd_json;
   stepsType.value = type
   stepsType.value = type
   xmjd_json[xmjd_json.length-1].type = type
   xmjd_json[xmjd_json.length-1].type = type
+  if (Object.prototype.hasOwnProperty.call(extra, 'c_ai_sczt')) {
+    projects.value.c_ai_sczt = extra.c_ai_sczt
+  }
   const params: any = {
   const params: any = {
     columnId: CONFIG.COLUMNIDARR[2],
     columnId: CONFIG.COLUMNIDARR[2],
     modelId: CONFIG.MODELIDARR[0],
     modelId: CONFIG.MODELIDARR[0],
     content: JSON.stringify({
     content: JSON.stringify({
       id: projects.value.id,
       id: projects.value.id,
-      c_xmjd_json: xmjd_json
+      c_xmjd_json: xmjd_json,
+      ...extra,
     }),
     }),
   }
   }
   const res = await api.updateContent(params)
   const res = await api.updateContent(params)
@@ -1836,6 +1922,191 @@ const updateTypeProject = async (type:any) => {
 
 
   }
   }
 }
 }
+
+/** 续标场景:将在管项目历史标书(docx)同步到投续标参考标书字段 c_ckbs */
+const syncRefBidFromSourceProject = async () => {
+  if (projects.value?.c_is_xb !== true) return
+  if (isDocxPath(projects.value?.c_ckbs)) return
+  const history = soureProjects.value?.c_history_tender
+  if (!isDocxPath(history)) return
+  const params: any = {
+    columnId: CONFIG.COLUMNIDARR[2],
+    modelId: CONFIG.MODELIDARR[0],
+    content: JSON.stringify({
+      id: projects.value.id,
+      c_ckbs: history,
+    }),
+  }
+  const res = await api.updateContent(params)
+  if ((res as any).code === 200) {
+    projects.value.c_ckbs = history
+  }
+}
+
+const triggerUpload = (kind: 'zbwj' | 'cgxqwj' | 'ckbs' | 'cqgg') => {
+  if (!canShowBidMakeActions.value) {
+    ElMessage.error("请先完成「报名并获取招标公告」")
+    return
+  }
+  const map: Record<string, HTMLInputElement | null | undefined> = {
+    zbwj: uploadInputZbwj.value,
+    cgxqwj: uploadInputCgxq.value,
+    ckbs: uploadInputCkbs.value,
+    cqgg: uploadInputCqgg.value,
+  }
+  const input = map[kind]
+  if (input) {
+    input.value = ''
+    input.click()
+  }
+}
+
+const onUploadFileChange = async (kind: 'zbwj' | 'cgxqwj' | 'ckbs' | 'cqgg', event: Event) => {
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (!file) return
+
+  const fieldMap: Record<string, string> = {
+    zbwj: 'c_zbwj',
+    cgxqwj: 'c_cgxqwj',
+    ckbs: 'c_ckbs',
+    cqgg: 'c_cqggwj',
+  }
+  const field = fieldMap[kind]
+  const name = file.name.toLowerCase()
+  const acceptRules: Record<string, { ok: boolean; tip: string }> = {
+    zbwj: { ok: name.endsWith('.pdf'), tip: '招标文件仅支持 .pdf 格式' },
+    cgxqwj: { ok: name.endsWith('.docx') || name.endsWith('.pdf'), tip: '采购需求文件仅支持 .docx / .pdf 格式' },
+    ckbs: { ok: name.endsWith('.docx'), tip: '参考标书仅支持 .docx 格式' },
+    cqgg: { ok: name.endsWith('.docx') || name.endsWith('.pdf'), tip: '澄清公告仅支持 .docx / .pdf 格式' },
+  }
+  const rule = acceptRules[kind]
+  if (rule && !rule.ok) {
+    ElMessage.error(rule.tip)
+    return
+  }
+
+  uploadLoading[kind] = true
+  const loading = ElLoading.service({
+    lock: true,
+    text: '文件上传中...',
+    background: 'rgba(0, 0, 0, 0.7)',
+  })
+  try {
+    const path = await getFIlePath({
+      columnId: CONFIG.COLUMNIDARR[2],
+      contentId: projects.value.id,
+      paraName: field,
+      type: 3,
+      file,
+    })
+    if (!path) {
+      ElMessage.error('上传失败')
+      return
+    }
+    const params: any = {
+      columnId: CONFIG.COLUMNIDARR[2],
+      modelId: CONFIG.MODELIDARR[0],
+      content: JSON.stringify({
+        id: projects.value.id,
+        [field]: path,
+      }),
+    }
+    const res = await api.updateContent(params)
+    if ((res as any).code === 200) {
+      projects.value[field] = path
+      ElMessage.success('上传成功')
+      // 澄清公告同步到澄清栏目(可选附件)
+      if (kind === 'cqgg') {
+        submitFileForm(file)
+      }
+    } else {
+      ElMessage.error((res as any).content || '保存文件失败')
+    }
+  } catch (e: any) {
+    ElMessage.error(e?.message || '上传失败')
+  } finally {
+    uploadLoading[kind] = false
+    loading.close()
+  }
+}
+
+/** 校验并生成标书 */
+const handleGenerateBid = async () => {
+  if (!canShowBidMakeActions.value) {
+    ElMessage.error("请先完成「报名并获取招标公告」")
+    return
+  }
+  if (!canGenerateBid.value) {
+    ElMessage.error("请先确认任务清单中的「招标公告与需求拆解」")
+    return
+  }
+  if (!projects.value.c_zbwj) {
+    ElMessage.error('请先上传招标文件')
+    return
+  }
+  if (!projects.value.c_cgxqwj) {
+    ElMessage.error('请先上传采购需求文件')
+    return
+  }
+  // 投标:参考标书必传;续标:优先用投续标表参考标书,无值则必须上传
+  if (!hasRefBidFile.value) {
+    if (projects.value.c_is_xb === true) {
+      ElMessage.error('续标项目未找到参考标书,请上传参考标书(.docx)')
+    } else {
+      ElMessage.error('投标项目请先上传参考标书(.docx)')
+    }
+    return
+  }
+
+  generateLoading.value = true
+  // 重新生成时清空已下载状态,禁止未下载就进入投标提交
+  clearBidDownloadedFlag()
+  generateProgressTip.value = ''
+  const loading = ElLoading.service({
+    lock: true,
+    text: '标书生成中...',
+    background: 'rgba(0, 0, 0, 0.7)',
+  })
+  try {
+    // 进入生成中:清空 AI 状态,避免仍残留「失败」导致刷新误回退
+    await updateTypeProject(3, { c_ai_sczt: '' })
+    projects.value.c_ai_sczt = ''
+    const res: any = await api.getGenerateTender({ txbId: projects.value.id })
+    if (res?.code === 200) {
+      // 任务5已由「待确认」完成;此处仅进入「获取标书」态
+      const xmjd_json = projects.value.c_xmjd_json
+      xmjd_json[xmjd_json.length - 1].type = 4
+      stepsType.value = 4 as any
+      projects.value.c_ai_sczt = ''
+      clearBidDownloadedFlag()
+      const params: any = {
+        columnId: CONFIG.COLUMNIDARR[2],
+        modelId: CONFIG.MODELIDARR[0],
+        content: JSON.stringify({
+          id: projects.value.id,
+          c_xmjd_json: xmjd_json,
+          c_ai_sczt: '',
+        }),
+      }
+      await api.updateContent(params)
+      generateProgressTip.value = '标书生成任务已提交,请点击「获取标书」查看进度或下载'
+      ElMessage.success(typeof res.content === 'string' && res.content ? res.content : '标书生成任务已提交')
+      bidFile.value.isFileFlag = hasRefBidFile.value
+      sessionStorage.setItem('isFileFlag', hasRefBidFile.value ? 'true' : 'false')
+    } else {
+      await updateTypeProject(2)
+      ElMessage.error(res?.content || res?.message || '标书文件生成失败')
+    }
+  } catch (e: any) {
+    await updateTypeProject(2)
+    ElMessage.error(e?.message || e?.response?.data?.content || '标书文件生成失败')
+  } finally {
+    generateLoading.value = false
+    loading.close()
+  }
+}
+
 // 更新项目文件
 // 更新项目文件
 const updateFIleProject = async (loading:any) => {
 const updateFIleProject = async (loading:any) => {
     let param = {
     let param = {
@@ -1880,51 +2151,250 @@ const getFIlePath = async (params: any) => {
 
 
 
 
 const bidFile = ref({
 const bidFile = ref({
-  flag: sessionStorage.getItem('bidFileFlag') == 'true' ? true : false, //是否获取到标书
+  flag: false, // 本次是否已成功下载标书(仅下载成功后为 true)
   swb:false,
   swb:false,
   jsb:false,
   jsb:false,
   isFileFlag: sessionStorage.getItem('isFileFlag') == 'true' ? true : false, //是否历史标书
   isFileFlag: sessionStorage.getItem('isFileFlag') == 'true' ? true : false, //是否历史标书
 })
 })
+const getBidLoading = ref(false)
+const generateProgressTip = ref('')
+
+const clearBidDownloadedFlag = () => {
+  bidFile.value.flag = false
+  sessionStorage.setItem('bidFileFlag', 'false')
+}
+
+/** 按 c_ai_sczt 同步下载态:非「完成」一律不可进入投标提交 */
+const syncBidDownloadFlagByAiStatus = () => {
+  const sczt = String(projects.value?.c_ai_sczt ?? '').trim()
+  if (sczt !== '完成') {
+    clearBidDownloadedFlag()
+    return
+  }
+  // 已完成:仅当本会话曾成功下载过才保持可进入;否则仍需点「获取标书」下载
+  const downloaded = sessionStorage.getItem('bidFileFlag') == 'true'
+  bidFile.value.flag = downloaded
+}
+
+/** 下载 AI 生成标书(c_ai_scbs) */
+const downloadAiBidFile = (aiscbs: string) => {
+  if (!aiscbs) {
+    clearBidDownloadedFlag()
+    ElMessage.error('暂无标书文件路径')
+    return false
+  }
+  let filename = aiscbs.split('/').pop() || ''
+  filename = filename.replace(/\?.*$/, '')
+  if (!filename.toLowerCase().includes('.docx')) {
+    clearBidDownloadedFlag()
+    ElMessage.error('暂无标书文件')
+    return false
+  }
+  UTIL.downloadFile(filename, CONFIG.FILEPATH + (aiscbs.replace('/data/model', '')))
+  bidFile.value.flag = true
+  sessionStorage.setItem('bidFileFlag', 'true')
+  ElMessage.success('获取标书成功')
+  return true
+}
+
+/** 投标提交阶段:下载当前投续标已生成的标书(不依赖获取流程) */
+const downloadCurrentBidFile = async () => {
+  downloadBidLoading.value = true
+  try {
+    const latest = await getTypeProjects(CONFIG.COLUMNIDARR[2], projects.value.id, 'id')
+    if (latest?.id) {
+      projects.value.c_ai_sczt = latest.c_ai_sczt
+      projects.value.c_ai_scbs = latest.c_ai_scbs
+    }
+    const sczt = String(projects.value?.c_ai_sczt ?? '').trim()
+    if (sczt !== '完成') {
+      ElMessage.error('标书尚未生成完成,暂不可下载')
+      return
+    }
+    const aiscbs = projects.value.c_ai_scbs || ''
+    if (!aiscbs) {
+      ElMessage.error('暂无标书文件路径')
+      return
+    }
+    let filename = aiscbs.split('/').pop() || ''
+    filename = filename.replace(/\?.*$/, '')
+    if (!filename.toLowerCase().includes('.docx')) {
+      ElMessage.error('暂无标书文件')
+      return
+    }
+    UTIL.downloadFile(filename, CONFIG.FILEPATH + (aiscbs.replace('/data/model', '')))
+    ElMessage.success('下载标书成功')
+  } catch (e: any) {
+    ElMessage.error(e?.message || '下载标书失败')
+  } finally {
+    downloadBidLoading.value = false
+  }
+}
+
+/** 生成失败:回退到上传/生成资料步骤,并清空 AI 生成状态,避免下次误判为失败 */
+const revertBidMakeOnGenerateFail = async () => {
+  clearBidDownloadedFlag()
+  generateProgressTip.value = '标书生成失败,请重新上传相关文件后再次生成'
+  projects.value.c_ai_sczt = ''
+  const xmjd_json = projects.value.c_xmjd_json
+  xmjd_json[xmjd_json.length - 1].type = 2
+  stepsType.value = 2 as any
+  const params: any = {
+    columnId: CONFIG.COLUMNIDARR[2],
+    modelId: CONFIG.MODELIDARR[0],
+    content: JSON.stringify({
+      id: projects.value.id,
+      c_xmjd_json: xmjd_json,
+      c_ai_sczt: '',
+    }),
+  }
+  const res = await api.updateContent(params)
+  if ((res as any).code !== 200) {
+    ElMessage.error((res as any).content || '回退进度失败,请稍后重试')
+  }
+}
+
+/** 将进度接口结果格式化为页面提示文案 */
+const formatTenderProgressTip = (progress: any): string => {
+  if (!progress || typeof progress !== 'object') {
+    return '标书正在生成中,请稍后再试'
+  }
+  const st = String(progress.status || '')
+  const msg = typeof progress.message === 'string' ? progress.message : ''
+  const total = Number(progress.total_steps) || 6
+  const doneList = Array.isArray(progress.completed_steps) ? progress.completed_steps : []
+  const doneCount = doneList.length
+  const stepName = progress.current_step_name || (progress.current_step != null ? `第 ${progress.current_step} 步` : '')
+
+  if (st === 'queued') {
+    return msg || '任务已排队,请稍后再获取标书'
+  }
+  if (st === 'processing') {
+    return msg || '任务处理中,请稍后再获取标书'
+  }
+  if (st === 'failed') {
+    return msg || '标书生成失败,请重新上传相关文件后再生成'
+  }
+  if (progress.current_step != null || doneCount > 0 || stepName) {
+    const stepPart = stepName
+      ? (st === 'running' ? `${stepName} 正在处理` : `${stepName} 处理完成`)
+      : ''
+    const base = `标书生成中:已完成 ${doneCount}/${total} 步`
+    return [base, stepPart || msg].filter(Boolean).join('。')
+  }
+  return msg || '标书正在生成中,请稍后再试'
+}
+
+/** 根据进度接口响应更新前端提示;silent=true 时只更新文案,不弹 Toast */
+const tipTenderProgress = (progress: any, silent = false) => {
+  // 生成过程中不可进入投标提交
+  clearBidDownloadedFlag()
+  const tip = formatTenderProgressTip(progress)
+  generateProgressTip.value = tip
+  if (silent) return
+  const st = String(progress?.status || '')
+  if (st === 'failed') {
+    ElMessage.error(tip)
+  } else {
+    ElMessage.info(tip)
+  }
+}
+
+/** 进入/刷新指挥页:若处于「获取标书」态,先请求进度并展示 */
+const refreshBidProgressTipOnEnter = async () => {
+  // 大阶段在「标书制作」,且子状态为获取标书(type=4)或生成中(type=3)
+  if (Number(stage.value) !== 4) return
+  const type = String(stepsType.value)
+  if (!(type === '4' || type === '3')) return
+  if (!projects.value?.id) return
+
+  const sczt = String(projects.value?.c_ai_sczt ?? '').trim()
+  if (sczt === '完成') {
+    generateProgressTip.value = bidFile.value.flag
+      ? '标书已下载,可进入投标提交'
+      : '标书已生成完成,请点击「获取标书」下载'
+    return
+  }
+  if (sczt === '失败') {
+    // 进入/刷新只展示提示,不自动回退;由用户点「获取标书」再执行回退清空
+    generateProgressTip.value = '标书生成失败,请点击「获取标书」回退并重新上传相关文件后再生成'
+    return
+  }
+
+  try {
+    const progress: any = await api.getTenderProgress(projects.value.id)
+    tipTenderProgress(progress, true)
+  } catch (err: any) {
+    const status = err?.response?.status
+    if (status === 404) {
+      generateProgressTip.value = '进度任务不存在或尚未就绪,请稍后点击「获取标书」刷新'
+    } else {
+      generateProgressTip.value = '标书正在生成中,进度暂不可用,请稍后点击「获取标书」刷新'
+    }
+  }
+}
+
 const getBidFile = async () => {
 const getBidFile = async () => {
+  if (!projects.value?.id) {
+    ElMessage.error('未找到投续标数据')
+    return
+  }
+  getBidLoading.value = true
   const loading = ElLoading.service({
   const loading = ElLoading.service({
     lock: true,
     lock: true,
     text: '获取中...',
     text: '获取中...',
     background: 'rgba(0, 0, 0, 0.7)',
     background: 'rgba(0, 0, 0, 0.7)',
   })
   })
-  setTimeout(() => {
-    // const fileName = `杨浦区图书馆总馆物业管理服务项目投标文件.docx`
-    // UTIL.downloadFile(fileName, filePath)
-    const aiscbs = projects.value.c_ai_scbs || "";
-    // let aiscbs = "/data/model/shenqin_data/57f7bf655e994fa8a8867e4bc55e8709/1779-杨浦区图书馆总馆物业管理服务项目招标文件.pdf-ca9d5daf-443b-44c6-a1f3-bf43890a55d8.docx"
-    if(aiscbs){
-      let filename = aiscbs.split('/').pop() || ''
-      filename = filename.replace(/\?.*$/, '')
-      if(filename.includes('.docx')){
-        UTIL.downloadFile(filename, CONFIG.FILEPATH + (aiscbs.replace("/data/model","")))
-        loading.close()
-        ElMessage.success('获取标书成功')
-        bidFile.value.flag = true
-        sessionStorage.setItem('bidFileFlag', "true")
-      }else{
-        sessionStorage.setItem('bidFileFlag', "false")
-        ElMessage.error('暂无标书文件')
+  try {
+    // 刷新投续标最新状态
+    const latest = await getTypeProjects(CONFIG.COLUMNIDARR[2], projects.value.id, 'id')
+    if (!latest?.id) {
+      ElMessage.error('获取投续标数据失败')
+      return
+    }
+    projects.value.c_ai_sczt = latest.c_ai_sczt
+    projects.value.c_ai_scbs = latest.c_ai_scbs
+
+    const sczt = String(latest.c_ai_sczt ?? '').trim()
+    if (sczt === '完成') {
+      const ok = downloadAiBidFile(latest.c_ai_scbs || '')
+      generateProgressTip.value = ok ? '标书已下载,可进入投标提交' : '标书状态为完成,但文件不可用,请联系管理员'
+      return
+    }
+    if (sczt === '失败') {
+      await revertBidMakeOnGenerateFail()
+      ElMessage.error('标书生成失败,请重新上传相关文件后再生成')
+      return
+    }
+
+    // 空值:仍在生成中,更新步骤提示(仅主动点击时可回退,刷新不改 stepsType)
+    clearBidDownloadedFlag()
+    loading.setText('标书生成中,查询进度...')
+    try {
+      const progress: any = await api.getTenderProgress(projects.value.id)
+      if (String(progress?.status || '') === 'failed') {
+        await revertBidMakeOnGenerateFail()
+        ElMessage.error(formatTenderProgressTip(progress) || '标书生成失败,请重新上传相关文件后再生成')
+        return
+      }
+      tipTenderProgress(progress, false)
+    } catch (err: any) {
+      const status = err?.response?.status
+      const detail = err?.response?.data?.detail || err?.message
+      if (status === 404) {
+        generateProgressTip.value = '进度任务不存在,请确认已发起生成'
+        ElMessage.error(generateProgressTip.value)
+      } else {
+        generateProgressTip.value = detail || '查询生成进度失败,请稍后重试'
+        ElMessage.warning(generateProgressTip.value)
       }
       }
-      
-    }else{
-      loading.close()
-      // bidFile.value.flag = true
-      ElMessage.error('获取标书失败')
     }
     }
-    
-  }, 3000);
-  
-  // if(bittype == 'swb'){
-  //   ElMessage.success('获取商务标书成功')
-  //   bidFile.value.swb = true
-  // }else if(bittype == 'jsb'){
-  //   ElMessage.success('获取技术标书成功')
-  //   bidFile.value.jsb = true
-  // }
+  } catch (e: any) {
+    ElMessage.error(e?.message || '获取标书失败')
+  } finally {
+    getBidLoading.value = false
+    loading.close()
+  }
 }
 }
 
 
 const handleChange = (val: any) => {
 const handleChange = (val: any) => {
@@ -1998,6 +2468,38 @@ const updateProjectParams = async (columnId:any,modelId:any,content:any) => {
   background: var(--bg);
   background: var(--bg);
 }
 }
 
 
+.bid-make-actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.bid-make-left {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.bid-make-right {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  justify-content: flex-end;
+  margin-left: auto;
+}
+.bid-make-tip {
+  color: var(--text2);
+  font-size: 13px;
+}
+.bid-progress-tip {
+  margin-top: 10px;
+  color: var(--primary);
+  font-size: 13px;
+  line-height: 1.5;
+}
+
 th, td {
 th, td {
   text-align: left;
   text-align: left;
   padding: 10px 12px;
   padding: 10px 12px;

+ 2 - 2
src/views/bidResult/BidResult.vue

@@ -234,8 +234,8 @@ import moment from 'moment'
 import api from '@/api/common'
 import api from '@/api/common'
 import CONFIG from '@/utils/config'
 import CONFIG from '@/utils/config'
 // import type { FormInstance } from 'element-plus'
 // import type { FormInstance } from 'element-plus'
-import addContent from '@/components/bidResult/AddContent.vue'
-import addProject from '@/components/bidResult/AddProject.vue'
+import addContent from '@/components/bidResult/addContent.vue'
+import addProject from '@/components/bidResult/addProject.vue'
 import MarkdownIt from 'markdown-it'
 import MarkdownIt from 'markdown-it'
 const activeTab = ref('ongoing')
 const activeTab = ref('ongoing')
 const selectedBid = ref('')
 const selectedBid = ref('')

+ 9 - 1
src/views/ledger/ProjectDetail.vue

@@ -438,7 +438,7 @@ const addProject = async () => {
       }
       }
       tempArr.value.push(str)
       tempArr.value.push(str)
     }
     }
-    let formData = {
+    let formData: any = {
         //项目
         //项目
         title: projects.value.c_xmmc,
         title: projects.value.c_xmmc,
         content: projects.value.content,
         content: projects.value.content,
@@ -458,6 +458,14 @@ const addProject = async () => {
         rwlcqd:JSON.stringify(CONFIG.TASKLIST),
         rwlcqd:JSON.stringify(CONFIG.TASKLIST),
         c_sfwc: false,
         c_sfwc: false,
        }
        }
+    // 历史标书须以 .docx 结尾才写入投续标参考标书 c_ckbs;否则不补,生成前必须上传
+    const historyTender = projects.value.c_history_tender || ''
+    if (typeof historyTender === 'string') {
+      const clean = historyTender.toLowerCase().replace(/\?.*$/, '').trim()
+      if (clean.endsWith('.docx')) {
+        formData.c_ckbs = historyTender
+      }
+    }
   
   
   const params = {
   const params = {
     content: JSON.stringify(formData),
     content: JSON.stringify(formData),