瀏覽代碼

侧栏分组与台账续标体验优化,并同步 README 变更记录。

对齐流程协同菜单;项目详情直达续签/续标;标书编制首屏与级联并行;续标/投标写入参考标书。

Co-authored-by: Cursor <cursoragent@cursor.com>
wdq 2 小時之前
父節點
當前提交
4be4324d5f

+ 1 - 0
.gitignore

@@ -4,6 +4,7 @@ node_modules
 /harness
 AGENTS.md
 sq_manage-原版.zip
+系统操作流程.md
 /docs
 /.cursor
 

+ 21 - 11
README.md

@@ -1,6 +1,6 @@
 # sq_manage
 
-> 申勤物业数字化平台 — 面向物业项目全生命周期的管理后台:合同到期预警、项目台账、市场拓展(续标 / 招标监测 / 标书 / 开标中标),集成外部「空间一张图」iframe,并在台账/建项中使用 Mapbox 面要素编辑。
+> 申勤物业数字化平台 — 面向物业项目全生命周期的管理后台:合同到期预警、项目台账、市场(续标 / 招标监测 / 标书 / 开标中标),集成外部「空间一张图」iframe,并在台账/建项中使用 Mapbox 面要素编辑。侧栏按「首页 / 项目中心 / 市场部 / 其他部门」分组(未落地模块可占位)。
 
 ---
 
@@ -115,7 +115,7 @@ sq_manage/
 ├── tsconfig.node.json                  # Node/Vite 侧 TS 配置
 ├── README.md                           # 本说明
 ├── API进度查询接口对接文档.md           # AI 标书生成进度接口说明
-├── .gitignore                          # Git 忽略规则(含 AGENTS.md、harness/)
+├── .gitignore                          # Git 忽略规则(含 AGENTS.md、harness/、系统操作流程.md
 ├── public/                             # 不经打包的静态资源
 │   ├── favicon.ico / favicon2.ico
@@ -208,7 +208,7 @@ sq_manage/
 │   │       └── GeoJsonEditor.vue       # GeoJSON 可视化编辑(jsoneditor)
 │   │
 │   ├── layouts/
-│   │   └── MainLayout.vue              # 主壳:侧栏导航 + 顶栏用户 + 内容区
+│   │   └── MainLayout.vue              # 主壳:分组侧栏(首页/项目中心/市场部等)+ 顶栏 + 内容区
 │   │
 │   ├── router/
 │   │   └── index.ts                    # ⭐ Hash 路由表
@@ -229,7 +229,7 @@ sq_manage/
 │   │   ├── config.ts                   # 读取 window.CONFIG(含兜底)
 │   │   ├── request.ts                  # Axios + token 拦截 / 无效 token 踢登录
 │   │   ├── encrypt.ts                  # 登录密码加密(依赖 createAuth)
-│   │   ├── util.ts                     # 金额格式化、UUID、下载、GeoJSON→WKT 
+│   │   ├── util.ts                     # 金额/UUID/下载/GeoJSON→WKT、项目标书→参考标书路径校验
 │   │   ├── displayUser.ts              # 登录名 → 展示名映射
 │   │   ├── renewalMath.ts              # 合同剩余天数 / 档位样式计算
 │   │   └── tree.ts                     # 在管项目列表 → 树节点
@@ -286,13 +286,15 @@ sq_manage/
 | 模块 | 路由 | 说明 |
 |------|------|------|
 | 登录 | `/` | 用户登录,token 存储到 localStorage |
-| 首页看板 | `/#/dashboard` | 项目统计、待办与近期项目 |
-| 一张图 | `/#/onemap` | 外部「空间一张图」iframe + postMessage 自动登录桥(非本仓 Mapbox) |
-| 项目台账 | `/#/projects` | 在管项目列表与详情(详情内含 Mapbox 面要素) |
-| 市场拓展 | `/#/market` | 市场拓展入口卡片(跳转续标 / 监测 / 标书 / 开标) |
-| 标书编制 | `/#/renewal` | 投续标 / 续签 Tab;核心指挥页 `/#/renewal-expire-detail/:id` |
-| 招标监测 | `/#/tender-pool` | 招标公告监测、意向、转投续标 |
-| 开标与中标 | `/#/bid-result` | 开标监控、中标登记、推送与建项 |
+| 首页 | `/#/dashboard` | 经营总览(统计 / 待办 / 近期项目);侧栏无子目录直达 |
+| 项目库 | `/#/projects` | 项目中心 · 在管项目列表与详情(详情内含 Mapbox) |
+| 项目分布 | `/#/onemap` | 项目中心 · 外部「空间一张图」iframe |
+| 市场拓展入口 | `/#/market` | 卡片导航(侧栏默认不展示,路由保留) |
+| 标书编制 | `/#/renewal` | 市场部 · 投续标 / 续签;指挥页 `/#/renewal-expire-detail/:id` |
+| 招标监测 | `/#/tender-pool` | 市场部 · 招标公告监测、意向、转投续标 |
+| 开标与中标 | `/#/bid-result` | 市场部 · 开标监控、中标登记、推送与建项 |
+
+侧栏另含运营 / 人事 / 采购 / 财务等分组占位(路由待落地)。
 
 ---
 
@@ -411,3 +413,11 @@ Internal use only.
 1、项目台账列表页面,显示增加过期数据,过期数据放在最后面显示
 2、续签标列表查询功能,根据项目名称查询接口修改
 3、空间一张图加载loading状态颜色调整, 与本项目其他loading状态颜色一致
+
+- **2026-09-20**
+1、侧栏对齐流程协同页:首页(无子目录)、项目中心、市场部及运营/人事/采购/财务等分组;未落地子项占位、菜单区内滚动
+2、台账「距到期」多选筛选;确认中标建项表单精简字段;统一页面字体继承
+3、项目详情:已有续签/续标流程时,预警条改为「进入续签/续标流程」直达入口
+4、标书编制:列表首屏与级联数据并行加载;修复新增弹窗区县→项目横向级联
+5、续标/投标创建投续标时,将所选在管项目的项目标书(`.docx`)写入参考标书 `c_ckbs`
+6、注册驻地/补充协议栏目 ID;本地手册 `系统操作流程.md` 纳入 `.gitignore`

+ 6 - 2
public/static/config/config.js

@@ -16,7 +16,9 @@ window.CONFIG = {
   // 10 1809:行政编码及片区 
   // 11 1832:澄清公告栏目 
   // 12 1833:续签项目栏目
-  COLUMNIDARR:[1743,1783,1779,1838,1837,1821,1781,1839,1813,1809,1832,1833],
+  // 13 1898:申勤驻地表
+  // 14 1899:补充协议表
+  COLUMNIDARR:[1743,1783,1779,1838,1837,1821,1781,1839,1813,1809,1832,1833,1898,1899],
   // 1 1852: 投标/续标栏目 
   // 2 1955: 招标公告栏目 
   // 3 1930: 招标监控 
@@ -25,7 +27,9 @@ window.CONFIG = {
   // 6 1948: 澄清公告栏目 
   // 7 1950: 续签项目栏目 
   // 8 1794: 申勤项目数据
-  MODELIDARR:[1852,1955,1930,1854,1954,1948,1950,1794],
+  // 9 2057: 申勤驻地表
+  // 10 2058: 补充协议表
+  MODELIDARR:[1852,1955,1930,1854,1954,1948,1950,1794,2057,2058],
 
   FILEPATH: 'http://121.43.55.7:65456',
 

+ 8 - 0
src/App.vue

@@ -21,4 +21,12 @@ body {
   padding: 0px;
 }
 
+/* 原生控件默认不继承字体,会导致侧栏分组按钮与链接字体外观不一致 */
+button,
+input,
+select,
+textarea {
+  font: inherit;
+}
+
 </style>

+ 24 - 210
src/components/bidResult/WonBidProjectForm.vue

@@ -2,10 +2,10 @@
   <!-- 中标建项表单字段区(不含提交逻辑) -->
   <el-form
     ref="formRef"
+    class="won-bid-form"
     :model="addForm"
     :rules="rules"
     label-width="auto"
-    style="max-height: 500px; overflow: auto"
   >
     <el-form-item label="项目名称" prop="c_xmmc">
       <el-input v-model="addForm.c_xmmc" placeholder="请输入项目名称" />
@@ -22,199 +22,6 @@
         @change="(v: any) => emit('area-change', v)"
       />
     </el-form-item>
-    <el-form-item label="市场部名称" prop="c_scbmc">
-      <el-input v-model="addForm.c_scbmc" placeholder="请输入市场部名称" />
-    </el-form-item>
-    <el-form-item label="委托内容(综合)" prop="c_wtnrzh">
-      <el-input v-model="addForm.c_wtnrzh" placeholder="请输入委托内容(综合)" />
-    </el-form-item>
-    <el-form-item label="招标人数(综合)" prop="c_zbrszh">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrszh"
-        controls-position="right"
-        placeholder="请输入招标人数(综合)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(综合)" prop="c_zbfyzh">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfyzh"
-        controls-position="right"
-        placeholder="请输入招标费用(综合)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(保安)" prop="c_wtnrba">
-      <el-input v-model="addForm.c_wtnrba" placeholder="请输入委托内容(保安)" />
-    </el-form-item>
-    <el-form-item label="招标人数(保安)" prop="c_zbrsba">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrsba"
-        controls-position="right"
-        placeholder="请输入招标人数(保安)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(保安)" prop="c_zbfyba">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfyba"
-        controls-position="right"
-        placeholder="请输入招标费用(保安)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(保洁)" prop="c_wtnrbj">
-      <el-input v-model="addForm.c_wtnrbj" placeholder="请输入委托内容(保洁)" />
-    </el-form-item>
-    <el-form-item label="招标人数(保洁)" prop="c_zbrsbj">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrsbj"
-        controls-position="right"
-        placeholder="请输入招标人数(保洁)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(保洁)" prop="c_zbfybj">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfybj"
-        controls-position="right"
-        placeholder="请输入招标费用(保洁)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(工程)" prop="c_wtnrgc">
-      <el-input v-model="addForm.c_wtnrgc" placeholder="请输入委托内容(工程)" />
-    </el-form-item>
-    <el-form-item label="招标人数(工程)" prop="c_zbrsgc">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrsgc"
-        controls-position="right"
-        placeholder="请输入招标人数(工程)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(工程)" prop="c_zbfygc">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfygc"
-        controls-position="right"
-        placeholder="请输入招标费用(工程)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(会务)" prop="c_wtnrhw">
-      <el-input v-model="addForm.c_wtnrhw" placeholder="请输入委托内容(会务)" />
-    </el-form-item>
-    <el-form-item label="招标人数(会务)" prop="c_zbrshw">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrshw"
-        controls-position="right"
-        placeholder="请输入招标人数(会务)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(会务)" prop="c_zbfyhw">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfyhw"
-        controls-position="right"
-        placeholder="请输入招标费用(会务)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(绿化)" prop="c_wtnrlh">
-      <el-input v-model="addForm.c_wtnrlh" placeholder="请输入委托内容(绿化)" />
-    </el-form-item>
-    <el-form-item label="招标人数(绿化)" prop="c_zbrslh">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrslh"
-        controls-position="right"
-        placeholder="请输入招标人数(绿化)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(绿化)" prop="c_zbfylh">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfylh"
-        controls-position="right"
-        placeholder="请输入招标费用(绿化)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(宿管)" prop="c_wtnrsg">
-      <el-input v-model="addForm.c_wtnrsg" placeholder="请输入委托内容(宿管)" />
-    </el-form-item>
-    <el-form-item label="招标人数(宿管)" prop="c_zbrssg">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrssg"
-        controls-position="right"
-        placeholder="请输入招标人数(宿管)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(宿管)" prop="c_zbfysg">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfysg"
-        controls-position="right"
-        placeholder="请输入招标费用(宿管)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(客服)" prop="c_wtnrkf">
-      <el-input v-model="addForm.c_wtnrkf" placeholder="请输入委托内容(客服)" />
-    </el-form-item>
-    <el-form-item label="招标人数(客服)" prop="c_zbrskf">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrskf"
-        controls-position="right"
-        placeholder="请输入招标人数(客服)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(客服)" prop="c_zbfykf">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfykf"
-        controls-position="right"
-        placeholder="请输入招标费用(客服)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(食堂)" prop="c_wtnrst">
-      <el-input v-model="addForm.c_wtnrst" placeholder="请输入委托内容(食堂)" />
-    </el-form-item>
-    <el-form-item label="招标人数(食堂)" prop="c_zbrsst">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrsst"
-        controls-position="right"
-        placeholder="请输入招标人数(食堂)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(食堂)" prop="c_zbfyst">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfyst"
-        controls-position="right"
-        placeholder="请输入招标费用(食堂)"
-      />
-    </el-form-item>
-    <el-form-item label="委托内容(其他)" prop="c_wtnrqt">
-      <el-input v-model="addForm.c_wtnrqt" placeholder="请输入委托内容(其他)" />
-    </el-form-item>
-    <el-form-item label="招标人数(其他)" prop="c_zbrsqt">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbrsqt"
-        controls-position="right"
-        placeholder="请输入招标人数(其他)"
-      />
-    </el-form-item>
-    <el-form-item label="招标费用(其他)" prop="c_zbfyqt">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_zbfyqt"
-        controls-position="right"
-        placeholder="请输入招标费用(其他)"
-      />
-    </el-form-item>
     <el-form-item label="合同面积(平方米)" prop="c_htmj">
       <el-input-number
         style="width: 100%"
@@ -310,22 +117,6 @@
         placeholder="请输入本年度合同月数"
       />
     </el-form-item>
-    <el-form-item label="延伸合同数" prop="c_yshts">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_yshts"
-        controls-position="right"
-        placeholder="请输入延伸合同数"
-      />
-    </el-form-item>
-    <el-form-item label="延伸合同额" prop="c_yshte">
-      <el-input-number
-        style="width: 100%"
-        v-model="addForm.c_yshte"
-        controls-position="right"
-        placeholder="请输入延伸合同额"
-      />
-    </el-form-item>
     <el-form-item label="合同总价" prop="c_htzj">
       <el-input-number
         style="width: 100%"
@@ -430,3 +221,26 @@ const ytModel = computed({
   set: (v: string) => emit('update:addFormYt', v),
 })
 </script>
+
+<style scoped>
+.won-bid-form {
+  max-height: 500px;
+  overflow-y: scroll;
+  /* 表单内容与右侧滚动条留出间距 */
+  padding-right: 12px;
+}
+
+/* 覆盖全局「默认隐藏、悬停才显示」的滚动条,保证弹窗内始终可见 */
+.won-bid-form::-webkit-scrollbar {
+  width: 6px;
+}
+.won-bid-form::-webkit-scrollbar-thumb {
+  display: block;
+  background-color: rgba(163, 166, 173, 0.6);
+  border-radius: 6px;
+}
+.won-bid-form::-webkit-scrollbar-track {
+  background-color: rgba(0, 0, 0, 0.04);
+  border-radius: 6px;
+}
+</style>

+ 1 - 34
src/components/bidResult/addProject.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="bid-content-page">
     <div class="bid-container">
-      <el-dialog v-model="dialogFormVisible" draggable title="提示信息" width="600" @close="close">
+      <el-dialog v-model="dialogFormVisible" draggable title="新项目信息登记" width="600" @close="close">
         <WonBidProjectForm
           ref="formCompRef"
           :add-form="addForm"
@@ -210,36 +210,6 @@ const addForm = ref({
   c_xfyt: '',
   c_hzyt: '',
   c_wtfs: '',
-  c_wtnrzh: '',
-  c_zbrszh: null,
-  c_zbfyzh: null,
-  c_wtnrba: '',
-  c_zbrsba: null,
-  c_zbfyba: null,
-  c_wtnrbj: '',
-  c_zbrsbj: null,
-  c_zbfybj: null,
-  c_wtnrgc: '',
-  c_zbrsgc: null,
-  c_zbfygc: null,
-  c_wtnrhw: '',
-  c_zbrshw: null,
-  c_zbfyhw: null,
-  c_wtnrlh: '',
-  c_zbrslh: null,
-  c_zbfylh: null,
-  c_wtnrsg: '',
-  c_zbrssg: null,
-  c_zbfysg: null,
-  c_wtnrkf: '',
-  c_zbrskf: null,
-  c_zbfykf: null,
-  c_wtnrst: '',
-  c_zbrsst: null,
-  c_zbfyst: null,
-  c_wtnrqt: '',
-  c_zbrsqt: null,
-  c_zbfyqt: null,
   c_htmj: null,
   c_htqyjf: '',
   c_scfwrq: '',
@@ -253,11 +223,8 @@ const addForm = ref({
   c_zgxmhtksrq: '',
   c_zgxmhtdqrq: '',
   c_bndhtys: null,
-  c_yshts: null,
-  c_yshte: null,
   c_htzj: null,
   c_zbdfqk: null,
-  c_scbmc: '',
   c_polygon: '',
   c_bcdzb: '',
   c_history_tender: '',

+ 2 - 2
src/components/bidResult/list/BidResultHistoryList.vue

@@ -60,7 +60,7 @@
           >
             确认中标 · 一键推送
           </el-button>
-          <el-button type="primary" class="btn enter-btn" @click="emit('open-file', '1')">
+          <!-- <el-button type="primary" class="btn enter-btn" @click="emit('open-file', '1')">
             上传合同
           </el-button>
           <el-button type="primary" class="btn enter-btn" @click="emit('open-file', '2')">
@@ -68,7 +68,7 @@
           </el-button>
           <el-button type="primary" class="btn enter-btn" @click="emit('open-file', '3')">
             上传采购资料
-          </el-button>
+          </el-button> -->
         </div>
       </div>
 

+ 34 - 6
src/components/ledger/ProjectDetailHeader.vue

@@ -19,12 +19,34 @@
         </div>
       </div>
       <div>
-        <el-button type="primary" class="btn enter-btn" @click="confirmRenew('xq')">
-          进入本项目续签
-        </el-button>
-        <el-button type="primary" class="btn enter-btn" @click="confirmRenew('txb')">
-          进入本项目续标
-        </el-button>
+        <!-- 已有进行中流程:只显示直达入口,隐藏发起按钮 -->
+        <template v-if="hasOngoingXq || hasOngoingTxb">
+          <el-button
+            v-if="hasOngoingXq"
+            type="primary"
+            class="btn enter-btn"
+            @click="emit('go-xq')"
+          >
+            进入续签流程
+          </el-button>
+          <el-button
+            v-if="hasOngoingTxb"
+            type="primary"
+            class="btn enter-btn"
+            @click="emit('go-txb')"
+          >
+            进入续标流程
+          </el-button>
+        </template>
+        <!-- 尚未发起:确认后创建或进入 -->
+        <template v-else>
+          <el-button type="primary" class="btn enter-btn" @click="confirmRenew('xq')">
+            进入本项目续签
+          </el-button>
+          <el-button type="primary" class="btn enter-btn" @click="confirmRenew('txb')">
+            进入本项目续标
+          </el-button>
+        </template>
       </div>
     </div>
 
@@ -52,11 +74,17 @@ import { ElMessageBox } from 'element-plus'
 
 const props = defineProps<{
   projects: any
+  /** 已存在续签栏目记录 */
+  hasOngoingXq?: boolean
+  /** 已存在投续标栏目记录 */
+  hasOngoingTxb?: boolean
 }>()
 
 const emit = defineEmits<{
   'renew-xq': []
   'renew-txb': []
+  'go-xq': []
+  'go-txb': []
 }>()
 
 /**

+ 26 - 26
src/components/renewal/BidRenewal.vue

@@ -22,7 +22,7 @@
           v-model:selected-region="selectedRegion"
           v-model:porject-type="porjectType"
           @query="queryProjects"
-          @add="dialogFormVisible = true"
+          @add="openAddDialog"
         />
       </div>
 
@@ -73,6 +73,7 @@ import CONFIG from '@/utils/config'
 import { COLUMN, MODEL } from '@/constants/dmsColumns'
 import { calcRemainingBase } from '@/utils/renewalMath'
 import { getDisplayUserName } from '@/utils/displayUser'
+import { resolveDocxHistoryTender } from '@/utils/util'
 import { useAdminArea } from '@/composables/useAdminArea'
 import { useNavigateToRenewalDetail } from '@/composables/useNavigateToRenewalDetail'
 import BidRenewalFilters from '@/components/renewal/list/BidRenewalFilters.vue'
@@ -81,7 +82,7 @@ import BidRenewalAddDialog from '@/components/renewal/list/BidRenewalAddDialog.v
 
 const columnId = COLUMN.TXB
 const { goToExpireDetail } = useNavigateToRenewalDetail()
-const { adminArea, glArea, finishTreeProjects, getFinishProjects, getAdminArea } = useAdminArea()
+const { adminArea, glArea, finishTreeProjects, loadAdminAreaWithProjects } = useAdminArea()
 
 /**
  * 列表「进入」:键名与历史一致(projectName/name/dmsId/progres)。
@@ -149,6 +150,15 @@ const finishObject = ref<any>({
 const zgfwOptions = ref<any>([])
 const selectedRegion = ref('')
 const dialogFormVisible = ref(false)
+/** 弹窗级联加载 Promise,打开新增时可复用等待 */
+let adminAreaLoadPromise: Promise<void> | null = null
+
+const openAddDialog = async () => {
+  dialogFormVisible.value = true
+  if (adminAreaLoadPromise) {
+    await adminAreaLoadPromise
+  }
+}
 const addFormZgfw = ref('')
 const addFormFwlx = ref([])
 const addFormYT = ref('')
@@ -259,10 +269,7 @@ const submitForm = (formEl: FormInstance | undefined) => {
           addForm.value.c_ytfw = addFormYT.value
           addForm.value.c_fwlx = addFormFwlx.value.join(',')
           addForm.value.c_xmjd_json = JSON.stringify(tempArr.value)
-          // 续标:保留选项目时写入的 c_ckbs;投标:不写历史标书,须在指挥页上传
-          if (!addForm.value.c_is_xb) {
-            addForm.value.c_ckbs = ''
-          }
+          // 续标/投标:选项目时已把父级或参考项目的项目标书写入 c_ckbs,提交时保留
 
           dialogFormVisible.value = false
           addProject()
@@ -281,15 +288,11 @@ const addProject = async () => {
 
   const res = await api.addContent(params)
   if ((res as any).code === 200) {
-    if (addForm.value.c_is_xb) {
-      ElMessage.success(
-        addForm.value.c_ckbs
-          ? '添加成功'
-          : '添加成功,未检测到历史参考标书(.docx),请在标书制作中上传'
-      )
-    } else {
-      ElMessage.success('添加成功,投标须在标书制作中上传参考标书(.docx)')
-    }
+    ElMessage.success(
+      addForm.value.c_ckbs
+        ? '添加成功'
+        : '添加成功,未检测到项目标书(.docx),请在标书制作中上传参考标书'
+    )
     page.value = 1
     initData()
   } else {
@@ -308,10 +311,13 @@ const queryProjects = () => {
 }
 
 onMounted(() => {
-  getFinishProjects()
-  getytProjects()
-  getAdminArea()
+  // 列表/统计首屏立即请求,不等级联数据
   initData()
+  getytProjects()
+  // 弹窗级联(在管项目 + 区划)后台加载,避免挡住列表展示
+  adminAreaLoadPromise = loadAdminAreaWithProjects().finally(() => {
+    adminAreaLoadPromise = null
+  })
 })
 
 const initData = () => {
@@ -528,14 +534,8 @@ const handleCascaderChange = (val: any) => {
   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.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 = ''
-  }
+  // 续标:父级项目标书 → c_ckbs;投标:参考项目标书 → c_ckbs(仅 .docx)
+  addForm.value.c_ckbs = resolveDocxHistoryTender(tempObj.c_history_tender)
   addForm.value.rwlcqd = JSON.stringify(CONFIG.TASKLIST)
 }
 

+ 13 - 12
src/composables/tenderpool/useMonitorAddProject.ts

@@ -8,6 +8,7 @@ import { ElMessage } from 'element-plus'
 import api from '@/api/common'
 import CONFIG from '@/utils/config'
 import { COLUMN, MODEL } from '@/constants/dmsColumns'
+import { resolveDocxHistoryTender } from '@/utils/util'
 
 export type UseMonitorAddProjectOptions = {
   /** 当前操作的招标公告 */
@@ -189,16 +190,12 @@ export function useMonitorAddProject(opts: UseMonitorAddProjectOptions) {
         ).format('YYYY-MM-DD HH:mm'),
         rwlcqd: JSON.stringify(CONFIG.TASKLIST),
       } as any
-      // 续标:在管项目历史标书须以 .docx 结尾才写入投续标参考标书 c_ckbs
-      const historyTender = finishObject.value.c_history_tender || ''
-      if (typeof historyTender === 'string') {
-        const clean = historyTender.toLowerCase().replace(/\?.*$/, '').trim()
-        if (clean.endsWith('.docx')) {
-          formData.c_ckbs = historyTender
-        }
-      }
+      // 续标:父级在管项目「项目标书」→ 投续标「参考标书」c_ckbs(仅 .docx)
+      const ckbs = resolveDocxHistoryTender(finishObject.value.c_history_tender)
+      if (ckbs) formData.c_ckbs = ckbs
     } else {
-      // 投标
+      // 投标:参考项目「项目标书」→ 投续标「参考标书」c_ckbs(仅 .docx)
+      const ckbs = resolveDocxHistoryTender(finishObject.value.c_history_tender)
       formData = {
         title: addForm.value.title,
         content: addForm.value.content,
@@ -218,7 +215,7 @@ export function useMonitorAddProject(opts: UseMonitorAddProjectOptions) {
         c_ytfw: addFormYT.value,
         c_fwlx: addFormFwlx.value.join(','),
         c_glqy: addForm.value.c_glqy,
-        c_ckbs: '',
+        c_ckbs: ckbs,
         c_xgzb: project.id,
         zbxmbh: project.id,
         zbmsxx: JSON.stringify({
@@ -268,11 +265,15 @@ export function useMonitorAddProject(opts: UseMonitorAddProjectOptions) {
         ElMessage.success(
           formData?.c_ckbs
             ? '续标创建成功'
-            : '续标创建成功,未检测到历史参考标书(.docx),请在标书制作中上传'
+            : '续标创建成功,未检测到项目标书(.docx),请在标书制作中上传参考标书'
         )
       } else {
         await opts.updateProject(project, 1, true)
-        ElMessage.success('投标创建成功,须在标书制作中上传参考标书(.docx)')
+        ElMessage.success(
+          formData?.c_ckbs
+            ? '投标创建成功'
+            : '投标创建成功,未检测到参考项目标书(.docx),请在标书制作中上传参考标书'
+        )
       }
     } else {
       ElMessage.error(isType ? '续标创建失败' : '投标创建失败')

+ 15 - 7
src/composables/useAdminArea.ts

@@ -92,13 +92,21 @@ export function useAdminArea(options: UseAdminAreaOptions = {}) {
           (item: any) =>
             item?.c_district_name && item?.c_district_code?.startsWith(districtPrefix)
         )
-        .map((item: any) => ({
-          label: item.c_district_name,
-          value: item.c_district_name,
-          children: treeSource.filter((item2: any) =>
-            item.c_district_name.includes(item2.c_sq)
-          )
-        }))
+        .map((item: any) => {
+          // 项目叶子节点去掉空 children,避免级联把叶子当成可展开层
+          const children = treeSource
+            .filter((item2: any) => item2?.c_sq && item.c_district_name.includes(item2.c_sq))
+            .map((p: any) => {
+              const { children: _c, ...rest } = p
+              return rest
+            })
+          return {
+            label: item.c_district_name,
+            value: item.c_district_name,
+            children,
+          }
+        })
+        .filter((item: AdminAreaTreeItem) => (item.children?.length ?? 0) > 0)
       const uniqueTree = Array.from(
         new Map(adminArea.value.map((item) => [item.value, item])).values()
       )

+ 8 - 0
src/constants/dmsColumns.ts

@@ -34,6 +34,10 @@ export const COLUMN = {
   CLARIFY: cols[10],
   /** [11] 续签项目栏目 */
   RENEW_XQ: cols[11],
+  /** [12] 申勤驻地表 */
+  STATION: cols[12],
+  /** [13] 补充协议表 */
+  SUPPLEMENT: cols[13],
 } as const
 
 /** 模型 ID(MODELIDARR 下标) */
@@ -54,4 +58,8 @@ export const MODEL = {
   RENEW_XQ: models[6],
   /** [7] 申勤项目数据 */
   SOURCE: models[7],
+  /** [8] 申勤驻地表 */
+  STATION: models[8],
+  /** [9] 补充协议表 */
+  SUPPLEMENT: models[9],
 } as const

+ 363 - 101
src/layouts/MainLayout.vue

@@ -1,69 +1,91 @@
 <template>
   <div class="layout">
-    <!-- 左侧菜单 -->
+    <!-- 左侧菜单:对齐流程协同页完整分组;未落地模块 path 为空 -->
     <aside class="sidebar">
       <div class="brand">
-        <!-- <div class="brand-icon">申</div> -->
-         <div class="brand-icon"><img :src="logo" alt="logo" style="width: 40px; height: 40px;" /></div>
+        <div class="brand-icon"><img :src="logo" alt="logo" style="width: 40px; height: 40px;" /></div>
         <div>
           <strong>申勤物业</strong>
           <span>数字化平台</span>
         </div>
       </div>
       <nav class="nav">
-        <div v-for="item in navItems" :key="item.path" @click="handleClick(item.path)">
-        <router-link 
-          v-if="item.path !== 'nav-label'"
-          :class="{ active: $route.path === item.path }"
-          :to="item.path"
-        >
-          {{ item.name }}
-        </router-link>
-        <div v-else class="nav-label">
-          {{ item.name }}
-        </div>
-        </div>
+        <template v-for="item in navTree" :key="item.id">
+          <!-- 无子目录:直接入口(如首页) -->
+          <router-link
+            v-if="item.type === 'link'"
+            :class="{ active: isActive(item.path) }"
+            :to="item.path"
+            @click="handleNavClick"
+          >
+            {{ item.name }}
+          </router-link>
+          <!-- 有子目录的分组 -->
+          <div v-else class="nav-group">
+            <button
+              type="button"
+              class="nav-group-btn"
+              :class="{ active: isGroupActive(item) }"
+              @click="toggleGroup(item.id)"
+            >
+              <span>{{ item.name }}</span>
+              <span class="nav-chevron">{{ expanded[item.id] ? '▾' : '▸' }}</span>
+            </button>
+            <div v-show="expanded[item.id]" class="nav-children">
+              <template v-for="child in item.children" :key="child.name">
+                <router-link
+                  v-if="child.path"
+                  :class="{ active: isActive(child.path) }"
+                  :to="child.path"
+                  @click="handleNavClick"
+                >
+                  {{ child.name }}
+                </router-link>
+                <span
+                  v-else
+                  class="nav-placeholder"
+                  title="模块待建设"
+                  @click="onPlaceholder(child.name)"
+                >
+                  {{ child.name }}
+                </span>
+              </template>
+            </div>
+          </div>
+        </template>
       </nav>
-      
     </aside>
 
-    <!-- 右侧内容区 -->
     <div class="main">
-      <!-- 顶部导航 -->
       <header class="topbar">
         <div>
           <h1>{{ currentTitle }}</h1>
           <p>{{ currentSubtitle }}</p>
         </div>
-        <!-- <el-input 
-          v-model="searchText" 
-          placeholder="搜索项目、人员…" 
-          class="search-input"
-        /> -->
-        <div class="sidebar-footer">
-          <el-dropdown @command="handleLogout">
-            <span class="el-dropdown-link">
-              <el-icon :size="20" color="var(--text2)">
-                <UserFilled />
-              </el-icon>
-              <span style="padding: 0 6px">
-                {{userName}}
+        <div class="topbar-right">
+          <!-- 子页可通过 Teleport to="#topbar-extra" 注入右上角操作按钮 -->
+          <div id="topbar-extra" class="topbar-extra"></div>
+          <div class="sidebar-footer">
+            <el-dropdown @command="handleLogout">
+              <span class="el-dropdown-link">
+                <el-icon :size="20" color="var(--text2)">
+                  <UserFilled />
+                </el-icon>
+                <span style="padding: 0 6px">
+                  {{ userName }}
+                </span>
               </span>
-            </span>
-            <template #dropdown>
-              <el-dropdown-menu>
-                <!-- <el-dropdown-item command="login">账号登录</el-dropdown-item> -->
-                <el-dropdown-item command="logout">退出登录</el-dropdown-item
-                >
-              </el-dropdown-menu>
-            </template>
-          </el-dropdown>
-
+              <template #dropdown>
+                <el-dropdown-menu>
+                  <el-dropdown-item command="logout">退出登录</el-dropdown-item>
+                </el-dropdown-menu>
+              </template>
+            </el-dropdown>
+          </div>
         </div>
       </header>
 
-      <!-- 路由内容 -->
-      <div :class="isGis == '/onemap' ? 'content-gis' : 'content'">
+      <div :class="isGis ? 'content-gis' : 'content'">
         <router-view />
       </div>
     </div>
@@ -71,7 +93,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, reactive, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { useUserStore } from '@/stores/user'
@@ -83,55 +105,203 @@ const logo = new URL('/static/image/logo.png', import.meta.url).href
 const userStore = useUserStore()
 const router = useRouter()
 const userName = getDisplayUserName()
-const navItems = [
-  { path: '/dashboard', name: '工作台' },
-  { path: '/onemap', name: '空间一张图' },
-  { path: '/projects', name: '项目台账' },
-  { path: 'nav-label', name: '市场拓展' },
-  { path: '/market', name: '市场拓展中心' },
-  { path: '/renewal', name: '标书编制' },
-  { path: '/tender-pool', name: '招标监测' },
-  { path: '/bid-result', name: '开标与中标' }
+
+/** path 为空表示暂未落地,仅展示菜单名 */
+type NavChild = { name: string; path?: string }
+type NavNode =
+  | { id: string; type: 'link'; name: string; path: string }
+  | { id: string; type: 'group'; name: string; children: NavChild[] }
+
+/**
+ * 侧栏对齐 word/2026-09-11-申勤数字化平台-流程协同.html 的 menus
+ * 已有业务挂现有路由;其余子项 path 留空,后续按部门落地再改
+ */
+const navTree: NavNode[] = [
+  { id: 'home', type: 'link', name: '首页', path: '/dashboard' },
+  // {
+  //   id: 'home',
+  //   name: '首页', 
+  //   children: [
+  //     { name: '经营总览', path: '/dashboard' },
+  //     // { name: '我的待办' },
+  //   ],
+  // },
+  {
+    id: 'projects',
+    type: 'group',
+    name: '项目中心',
+    children: [
+      { name: '项目库', path: '/projects' },
+      // { name: '流程总览' },
+      { name: '项目分布', path: '/onemap' },
+    ],
+  },
+  {
+    id: 'market',
+    type: 'group',
+    name: '市场部',
+    children: [
+      // { name: '部门工作台' },
+      { name: '招标监测', path: '/tender-pool' },
+      { name: '标书编制', path: '/renewal' },
+      { name: '开标与中标', path: '/bid-result' },
+      // { name: '投标 / 续标跟进' },
+    ],
+  },
+  {
+    id: 'ops',
+    type: 'group',
+    name: '运营部',
+    children: [
+      { name: '部门工作台' },
+      { name: '交接与定编' },
+      { name: '履约与项目报告' },
+    ],
+  },
+  {
+    id: 'hr',
+    type: 'group',
+    name: '人事部',
+    children: [
+      { name: '部门工作台' },
+      { name: '人员清单' },
+      { name: '证书管理' },
+      { name: '差额调配' },
+    ],
+  },
+  {
+    id: 'purchase',
+    type: 'group',
+    name: '采购部',
+    children: [{ name: '采购与物资' }],
+  },
+  {
+    id: 'finance',
+    type: 'group',
+    name: '财务部',
+    children: [
+      { name: '财务工作台' },
+      { name: '合同与回款' },
+    ],
+  },
+  // ,
+  // {
+  //   id: 'field',
+  //   type: 'group',
+  //   name: '现场条线',
+  //   children: [{ name: '现场工作台' }],
+  // },
+  // {
+  //   id: 'data',
+  //   type: 'group',
+  //   name: '资料中心',
+  //   children: [
+  //     { name: '项目资料' },
+  //     { name: '流程规则' },
+  //     { name: '数据回填记录' },
+  //   ],
+  // },
+  // {
+  //   id: 'reports',
+  //   type: 'group',
+  //   name: '报表中心',
+  //   children: [{ name: '项目协同报表' }],
+  // },
 ]
-const isGis = ref('')
+
+const flatLinks = computed(() => {
+  const list: Array<{ name: string; path: string }> = []
+  for (const n of navTree) {
+    if (n.type === 'link') list.push({ name: n.name, path: n.path })
+    else list.push(...n.children.filter((c): c is NavChild & { path: string } => !!c.path))
+  }
+  return list
+})
+
+const expanded = reactive<Record<string, boolean>>(
+  Object.fromEntries(
+    navTree
+      .filter((n): n is Extract<NavNode, { type: 'group' }> => n.type === 'group')
+      .map((g) => [g.id, g.id === 'projects' || g.id === 'market'])
+  )
+)
+
+const routePath = computed(() => router.currentRoute.value.path)
+
+const isGis = computed(() => routePath.value === '/onemap' || routePath.value.startsWith('/onemap'))
+
+const isActive = (path: string) => {
+  const p = routePath.value
+  if (path === '/dashboard') return p === '/dashboard' || p === '/'
+  return p === path || p.startsWith(path + '/')
+}
+
+const isGroupActive = (item: NavNode) => {
+  if (item.type !== 'group') return false
+  return item.children.some((c) => c.path && isActive(c.path))
+}
+
+const toggleGroup = (id: string) => {
+  expanded[id] = !expanded[id]
+}
+
+const findGroupIdByPath = (p: string): string | undefined => {
+  for (const n of navTree) {
+    if (n.type !== 'group') continue
+    if (n.children.some((c) => c.path && (p === c.path || p.startsWith(c.path + '/')))) return n.id
+  }
+  if (p.includes('project-detail')) return 'projects'
+  if (p.includes('renewal')) return 'market'
+  return undefined
+}
+
+/** 进入子路由时自动展开所属分组 */
+watch(
+  routePath,
+  (p) => {
+    const gid = findGroupIdByPath(p)
+    if (gid) expanded[gid] = true
+  },
+  { immediate: true }
+)
+
 const currentTitle = computed(() => {
-  // sessionStorage.setItem('activeTab','ongoing')
-  const item = navItems.find(n => n.path === router.currentRoute.value.path)
-  isGis.value = item?.path ?? ''
-  if(!item){
-    return sessionStorage.getItem('name')
-  }else{
-    return item ? item.name : '工作台'
+  const p = routePath.value
+  const hit = flatLinks.value.find((n) => p === n.path)
+  if (hit) return hit.name
+  if (p.includes('project-detail')) return sessionStorage.getItem('name') || '项目详情'
+  if (p.includes('renewal-expire-detail') || p.includes('renewal-xq-detail') || p.includes('renewal-detail')) {
+    return sessionStorage.getItem('name') || '续标详情'
   }
+  return sessionStorage.getItem('name') || '首页'
 })
 
-const handleClick = (_path: string) => {
-  sessionStorage.setItem('activeTab','ongoing')
+const handleNavClick = () => {
+  sessionStorage.setItem('activeTab', 'ongoing')
+}
+
+const onPlaceholder = (name: string) => {
+  ElMessage.info(`「${name}」模块待建设`)
 }
 
 const currentSubtitle = computed(() => {
-  if (router.currentRoute.value.path === '/dashboard') {
-    return '今日待办与关键指标 · 续标预警优先展示'
-  }else if (router.currentRoute.value.path === '/onemap') {
-    return '数据可视化平台 · 空间一张图'
-  }else if (router.currentRoute.value.path === '/projects') {
-    return '在管项目一览'
-  }else if (router.currentRoute.value.path === '/market') {
-    return '招标监测 · 标书编制 · 投标履约 · 项目续标'
-  }else if (router.currentRoute.value.path === '/renewal') {
-    return '合同到期预警 · 续标全流程跟进'
-  }else if (router.currentRoute.value.path === '/tender-pool') {
-    return '政府采购网抓取 · 续标监控 · 智能匹配'
-  }else if (router.currentRoute.value.path === '/bid-workbench') {
-    return '需求拆解 · 数据映射 · 分步确认 · 生成导出'
-  }else if (router.currentRoute.value.path === '/bid-result') {
-    return '投标登记 · 开标监控 · 结果抓取 · 中标推送'
-  }else if (router.currentRoute.value.path.includes('project-detail/') || router.currentRoute.value.path.includes('renewal-detail/') || router.currentRoute.value.path.includes('renewal-expire-detail/')) {
-    const addr = (sessionStorage.getItem('projectName'))
-    return addr
-  }else {
-    return '欢迎使用申勤物业数字化平台'
+  const p = routePath.value
+  if (p === '/dashboard') return '首页 · 今日待办与关键指标'
+  if (p === '/onemap') return '项目中心 · 项目分布'
+  if (p === '/projects') return '项目中心 · 项目库'
+  if (p === '/market') return '市场部 · 招标监测 · 标书编制 · 开标中标'
+  if (p === '/renewal') return '市场部 · 标书编制 · 续标全流程跟进'
+  if (p === '/tender-pool') return '市场部 · 政府采购网抓取 · 招标监控'
+  if (p === '/bid-result') return '市场部 · 开标监控 · 中标推送'
+  if (
+    p.includes('project-detail/') ||
+    p.includes('renewal-detail/') ||
+    p.includes('renewal-expire-detail/') ||
+    p.includes('renewal-xq-detail/')
+  ) {
+    return sessionStorage.getItem('projectName') || ''
   }
+  return '欢迎使用申勤物业数字化平台'
 })
 
 const handleLogout = () => {
@@ -144,17 +314,21 @@ const handleLogout = () => {
 <style scoped>
 .layout {
   display: flex;
-  min-height: 100vh;
+  height: 100vh;
+  overflow: hidden;
 }
 
 .sidebar {
   width: 220px;
   flex-shrink: 0;
+  height: 100%;
+  min-height: 0;
   background: var(--surface);
   border-right: 1px solid var(--border);
   padding: 16px 12px;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
 }
 
 .brand {
@@ -162,12 +336,12 @@ const handleLogout = () => {
   align-items: center;
   gap: 10px;
   padding: 8px 10px 20px;
+  flex-shrink: 0;
 }
 
 .brand-icon {
   width: 36px;
   height: 36px;
-  /* background: var(--primary); */
   border-radius: 8px;
   color: #fff;
   display: flex;
@@ -189,14 +363,40 @@ const handleLogout = () => {
 
 .nav {
   flex: 1;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  font-family: inherit;
+  font-size: 13px;
+  /* 菜单区滚动条常显,避免整页滚动 */
+  scrollbar-width: thin;
+  scrollbar-color: rgba(163, 166, 173, 0.7) transparent;
+}
+
+.nav::-webkit-scrollbar {
+  width: 6px;
+}
+
+.nav::-webkit-scrollbar-thumb {
+  display: block;
+  background-color: rgba(163, 166, 173, 0.55);
+  border-radius: 6px;
 }
 
-.nav a {
+.nav::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.nav a,
+.nav-placeholder {
   display: block;
   padding: 8px 12px;
   border-radius: 6px;
   color: var(--text2);
+  font-family: inherit;
   font-size: 13px;
+  font-weight: 400;
+  line-height: 1.5;
   margin-bottom: 2px;
   text-decoration: none;
 }
@@ -211,12 +411,74 @@ const handleLogout = () => {
   color: var(--primary);
   font-weight: 500;
 }
-.nav-label {
-    font-size: 11px;
-    color: var(--text3);
-    padding: 12px 12px 6px;
-    font-weight: 600;
+
+.nav-placeholder {
+  color: var(--text3);
+  cursor: default;
+  opacity: 0.75;
+}
+
+.nav-placeholder:hover {
+  background: #f8fafb;
+}
+
+.nav-group {
+  margin-bottom: 4px;
 }
+
+.nav-group-btn {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border: 0;
+  background: transparent;
+  color: var(--text2);
+  font-family: inherit;
+  font-size: 13px;
+  font-weight: 400;
+  line-height: 1.5;
+  cursor: pointer;
+  border-radius: 6px;
+}
+
+.nav-group-btn:hover,
+.nav-group-btn.active {
+  color: var(--text);
+}
+
+.nav-chevron {
+  font-size: 12px;
+  opacity: 0.7;
+  font-weight: 400;
+}
+
+.nav-children {
+  padding-left: 6px;
+}
+
+.nav-children a,
+.nav-children .nav-placeholder {
+  padding-left: 16px;
+  font-size: 13px;
+}
+
+.topbar-right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-left: auto;
+}
+
+.topbar-extra {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
 .sidebar-footer {
   display: flex;
   cursor: pointer;
@@ -239,6 +501,8 @@ const handleLogout = () => {
   display: flex;
   flex-direction: column;
   min-width: 0;
+  min-height: 0;
+  height: 100%;
   background: var(--bg);
   overflow: hidden;
 }
@@ -265,20 +529,13 @@ const handleLogout = () => {
   color: var(--text3);
 }
 
-.search-input {
-  width: 240px;
-}
-
 .content {
   padding: 20px 24px 32px;
-  /* flex: 1; */
   height: calc(100vh - 70px);
   overflow-y: auto;
 }
 .content-gis {
   padding: 0 0;
-  /* flex: 1; */
-  /* height: calc(70px); */
   height: 100%;
   overflow-y: auto;
 }
@@ -292,14 +549,19 @@ const handleLogout = () => {
 }
 .el-dropdown,
 .el-dropdown * {
-  outline: none !important; /* 确保下拉触发器没有轮廓 */
+  outline: none !important;
 }
 
 @media (max-width: 768px) {
   .sidebar {
     width: 60px;
   }
-  .brand span, .brand strong, .nav a, .sidebar-footer span {
+  .brand span,
+  .brand strong,
+  .nav a,
+  .nav-placeholder,
+  .nav-group-btn,
+  .sidebar-footer span {
     display: none;
   }
 }

+ 2 - 2
src/styles/element-theme-green.css

@@ -77,8 +77,8 @@
   --el-radius-xl: 16px;
   --el-radius-full: 999px;
 
-  /* 字体 */
-  --el-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  /* 字体:与 App.vue body 保持一致,避免 Element 控件与页面正文字体不一致 */
+  --el-font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
   --el-font-size-extra-large: 20px;
   --el-font-size-large: 16px;
   --el-font-size-medium: 14px;

+ 12 - 1
src/utils/util.ts

@@ -102,10 +102,21 @@ function geojsonToWkt(geojson: any) {
   throw new Error('不支持的几何类型: ' + geom.type);
 }
 
+/**
+ * 在管「项目标书」c_history_tender → 投续标「参考标书」c_ckbs。
+ * 仅当路径为 .docx(可带 query)时返回原串,否则返回空串。
+ */
+export function resolveDocxHistoryTender(historyTender: unknown): string {
+  if (typeof historyTender !== 'string' || !historyTender.trim()) return ''
+  const clean = historyTender.toLowerCase().replace(/\?.*$/, '').trim()
+  return clean.endsWith('.docx') ? historyTender : ''
+}
+
 export default {
   formatCurrency,
   generateUUID,
   downloadFile,
   copyText,
-  geojsonToWkt
+  geojsonToWkt,
+  resolveDocxHistoryTender,
 }

+ 1 - 1
src/views/home/Dashboard.vue

@@ -138,7 +138,7 @@ const todoList = ref<TodoItem[]>([
 ])
 
 const quickLinks = ref([
-  { title: '市场拓展中心', desc: '招标 · 标书 · 投标 · 续标', primary: false, path: '/market' },
+  // { title: '市场拓展中心', desc: '招标 · 标书 · 投标 · 续标', primary: false, path: '/market' },
   { title: '项目续标', desc: '续标项目列表 · 全流程跟进', primary: false, path: '/renewal' },
   // { title: '空间一张图', desc: '数据可视化展示 · 空间一张图', primary: false, path: '/gis' },
   { title: '招标监测', desc: '政府采购网抓取 · 续标监控 · 智能匹配', primary: false, path: '/tender-pool' },

+ 48 - 14
src/views/ledger/ProjectDetail.vue

@@ -2,8 +2,12 @@
   <div class="project-page">
     <ProjectDetailHeader
       :projects="projects"
+      :has-ongoing-xq="!!ongoingXq"
+      :has-ongoing-txb="!!ongoingTxb"
       @renew-xq="handleRenewaXQlClick"
       @renew-txb="handleRenewalClick"
+      @go-xq="goOngoingXq"
+      @go-txb="goOngoingTxb"
     />
     <ProjectDetailInfo
       :projects="projects"
@@ -26,6 +30,7 @@ import CONFIG from '@/utils/config'
 import { COLUMN, MODEL } from '@/constants/dmsColumns'
 import { getDisplayUserName } from '@/utils/displayUser'
 import { calcRemainingBase } from '@/utils/renewalMath'
+import { resolveProgresFromXmjdJson } from '@/composables/useNavigateToRenewalDetail'
 import ProjectDetailHeader from '@/components/ledger/ProjectDetailHeader.vue'
 import ProjectDetailInfo from '@/components/ledger/ProjectDetailInfo.vue'
 import ProjectDetailMap from '@/components/ledger/ProjectDetailMap.vue'
@@ -42,6 +47,10 @@ const wtfwOptions = ref<any>([])
 const jffsOptions = ref<any>([])
 const historyBidList = ref<any>([])
 
+/** 当前在管项目已关联的进行中续标 / 续签记录(有则顶栏显示直达按钮) */
+const ongoingTxb = ref<any | null>(null)
+const ongoingXq = ref<any | null>(null)
+
 onMounted(() => {
   initCName()
   initData()
@@ -126,9 +135,46 @@ const getProjects = async () => {
       mapSectionRef.value?.getBounds(geojson)
       mapSectionRef.value?.renderGeoJson(projects.value)
     }
+
+    // 查是否已有续签 / 续标流程,决定顶栏直达按钮
+    await loadOngoingFlows()
   }
 }
 
+/** 按项目 guid 查投续标、续签栏目是否已有记录 */
+const loadOngoingFlows = async () => {
+  ongoingTxb.value = null
+  ongoingXq.value = null
+  const guid = projects.value?.c_guid
+  if (!guid) return
+
+  const [txbList, xqList] = await Promise.all([
+    getXMProjects(guid, 'xmbh', COLUMN.TXB),
+    getXMProjects(guid, 'c_xmbh', COLUMN.RENEW_XQ),
+  ])
+  if (txbList.length > 0) ongoingTxb.value = txbList[0]
+  if (xqList.length > 0) ongoingXq.value = xqList[0]
+}
+
+const writeFlowSession = (record: any) => {
+  sessionStorage.setItem('projectName', projects.value.c_xmmc || '')
+  sessionStorage.setItem('name', projects.value.c_xmmc || '')
+  sessionStorage.setItem('dmsId', String(record.id))
+  sessionStorage.setItem('progres', resolveProgresFromXmjdJson(record.c_xmjd_json))
+}
+
+const goOngoingTxb = () => {
+  if (!ongoingTxb.value) return
+  writeFlowSession(ongoingTxb.value)
+  router.push(`/renewal-expire-detail/${ongoingTxb.value.id}`)
+}
+
+const goOngoingXq = () => {
+  if (!ongoingXq.value) return
+  writeFlowSession(ongoingXq.value)
+  router.push(`/renewal-xq-detail/${ongoingXq.value.id}`)
+}
+
 /**
  * 台账详情剩余天数字段:复用 calcRemainingBase 的日期差,
  * 但保留本页原语义——days 可为负、daysClass 固定 ok、year 用服务期限小数上取整。
@@ -182,13 +228,7 @@ const handleRenewalClick = () => {
   getXMProjects(projects.value.c_guid, 'xmbh', COLUMN.TXB).then(async (res) => {
     if (res.length > 0) {
       // 直接进入投续标详情页面
-      sessionStorage.setItem('projectName', projects.value.c_xmmc)
-      sessionStorage.setItem('name', projects.value.c_xmmc)
-      sessionStorage.setItem('dmsId', res[0].id)
-      sessionStorage.setItem(
-        'progres',
-        JSON.parse(res[0].c_xmjd_json)[JSON.parse(res[0].c_xmjd_json).length - 1].xmjd
-      )
+      writeFlowSession(res[0])
       router.push(`/renewal-expire-detail/${res[0].id}`)
     } else {
       const ids = await addProject()
@@ -209,13 +249,7 @@ const handleRenewaXQlClick = () => {
   getXMProjects(projects.value.c_guid, 'c_xmbh', COLUMN.RENEW_XQ).then(async (res) => {
     if (res.length > 0) {
       // 直接进入续签详情页面
-      sessionStorage.setItem('projectName', projects.value.c_xmmc)
-      sessionStorage.setItem('name', projects.value.c_xmmc)
-      sessionStorage.setItem('dmsId', res[0].id)
-      sessionStorage.setItem(
-        'progres',
-        JSON.parse(res[0].c_xmjd_json)[JSON.parse(res[0].c_xmjd_json).length - 1].xmjd
-      )
+      writeFlowSession(res[0])
       router.push(`/renewal-xq-detail/${res[0].id}`)
     } else {
       const ids = await addXQProject()

+ 99 - 17
src/views/ledger/Projects.vue

@@ -14,6 +14,23 @@
             @clear="queryProjects"
             class="search-input"
           />
+          <el-select
+            v-model="expireFilter"
+            multiple
+            clearable
+            collapse-tags
+            collapse-tags-tooltip
+            placeholder="距到期"
+            class="expire-filter"
+            @change="queryProjects"
+          >
+            <el-option
+              v-for="opt in EXPIRE_FILTER_OPTIONS"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
           <el-button type="primary" class="btn enter-btn" @click="queryProjects">查询</el-button>
         </div>
         <div class="empty-state" v-if="total === 0">
@@ -87,6 +104,17 @@ const router = useRouter()
 
 const columnId = COLUMN.SOURCE
 
+/** 距到期筛选项;顺序与列表分类展示优先级一致(未过期近→远,已过期最后) */
+const EXPIRE_FILTER_OPTIONS = [
+  { value: 'lte60', label: '剩余≤60天' },
+  { value: 'lte90', label: '剩余61-90天' },
+  { value: 'lte180', label: '剩余91-180天' },
+  { value: 'gt180', label: '剩余>180天' },
+  { value: 'expired', label: '已过期' },
+] as const
+
+type ExpireFilterValue = (typeof EXPIRE_FILTER_OPTIONS)[number]['value']
+
 interface ProjectItem {
   title: string
   c_xmmc: string
@@ -97,21 +125,66 @@ interface ProjectItem {
   c_zgxmhtdqrq: string
   days?: string
   daysClass?: string
+  /** 距到期天数(有符号,用于筛选) */
+  diffDays?: number | null
   name?: string
   projectName?: string
   code?: string
 }
 
 /** 台账列表展示文案:复用 diff,保留「已过期/今天到期/剩余」包装 */
-const calculateRemainingDays = (dateStr: string): { days: string; daysClass: string } => {
+const calculateRemainingDays = (
+  dateStr: string
+): { days: string; daysClass: string; diffDays: number | null } => {
   const base = calcRemainingDaysOnly(dateStr)
-  if (!base.days) return { days: '', daysClass: '' }
-  const n = Number(base.days)
-  if (n < 0) return { days: `已过期${Math.abs(n)}天`, daysClass: 'grey‌' }
-  if (n === 0) return { days: '今天到期', daysClass: 'grey‌' }
-  return { days: `剩余${n}天`, daysClass: base.daysClass }
+  if (!base.days) return { days: '', daysClass: '', diffDays: null }
+  const diffDays = Number(base.days)
+  if (Number.isNaN(diffDays)) return { days: '', daysClass: '', diffDays: null }
+  if (diffDays < 0) return { days: `已过期${Math.abs(diffDays)}天`, daysClass: 'grey‌', diffDays }
+  if (diffDays === 0) return { days: '今天到期', daysClass: 'grey‌', diffDays }
+  return { days: `剩余${diffDays}天`, daysClass: base.daysClass, diffDays }
 }
+
+/** 将有符号天数映射到筛选项 */
+const matchExpireFilter = (diffDays: number | null | undefined, filter: ExpireFilterValue): boolean => {
+  if (diffDays === null || diffDays === undefined) return false
+  switch (filter) {
+    case 'expired':
+      return diffDays < 0
+    case 'lte60':
+      return diffDays > 0 && diffDays <= 60
+    case 'lte90':
+      return diffDays > 60 && diffDays <= 90
+    case 'lte180':
+      return diffDays > 90 && diffDays <= 180
+    case 'gt180':
+      return diffDays > 180
+    default:
+      return false
+  }
+}
+
+/**
+ * 距到期分类排序:
+ * 1) 未过期(含今天)按到期日升序
+ * 2) 已过期按到期日倒序
+ * 3) 无合同起止(无到期日)固定放在最后
+ */
+const sortByExpireCategory = (list: ProjectItem[]): ProjectItem[] => {
+  const noDate = list.filter((item) => item.diffDays === null || item.diffDays === undefined)
+  const expired = list.filter((item) => item.diffDays !== null && item.diffDays !== undefined && item.diffDays < 0)
+  const active = list.filter((item) => item.diffDays !== null && item.diffDays !== undefined && item.diffDays >= 0)
+  const byExpireAsc = (a: ProjectItem, b: ProjectItem) =>
+    moment(a.c_zgxmhtdqrq).valueOf() - moment(b.c_zgxmhtdqrq).valueOf()
+  return [...active]
+    .sort(byExpireAsc)
+    .concat([...expired].sort(byExpireAsc).reverse())
+    .concat(noDate)
+}
+
 const searchText = ref('')
+/** 距到期多选;空数组表示不过滤 */
+const expireFilter = ref<ExpireFilterValue[]>([])
 const pageSize = ref(10)
 const page = ref(1)
 const total = ref(0)
@@ -167,9 +240,8 @@ const getAllProjects = async () => {
       ...item,
       ...calculateRemainingDays((item as any)['c_zgxmhtdqrq'])
     }))
-    const lastData = allData.filter((item: any) => item.days.includes('已过期'))
-    const todayData = allData.filter((item: any) => !item.days.includes('已过期'))
-    staticData.value = todayData.concat(lastData.reverse())
+    // 默认顺序:未过期近→远 → 已过期 → 无合同起止
+    staticData.value = sortByExpireCategory(allData)
     total.value = (res as any).content.count
     getCurrentPageData()
   }else{
@@ -177,18 +249,23 @@ const getAllProjects = async () => {
     total.value = 0
   }
 }
-// 计算属性:截取当前页数据
+// 计算属性:截取当前页数据(名称 + 距到期多选;多分类时保持分类排序)
 const getCurrentPageData = () => {
   const start = (page.value-1) * pageSize.value
   const end = start + pageSize.value
-  if(searchText.value){
-     const itemData = staticData.value.filter((item: any) => item.c_xmmc.includes(searchText.value))
-     projects.value = itemData.slice(start, end)
-     total.value = itemData.length
-  }else{
-    projects.value = staticData.value.slice(start, end)
-    total.value = staticData.value.length
+  let filtered = staticData.value
+  if (searchText.value) {
+    filtered = filtered.filter((item: any) => item.c_xmmc?.includes(searchText.value))
   }
+  if (expireFilter.value.length > 0) {
+    filtered = filtered.filter((item) =>
+      expireFilter.value.some((f) => matchExpireFilter(item.diffDays, f))
+    )
+  }
+  // 始终按分类顺序:未过期 → 已过期 → 无合同起止
+  filtered = sortByExpireCategory(filtered)
+  projects.value = filtered.slice(start, end)
+  total.value = filtered.length
 }
 // 按项目名称查询项目
 const queryProjects = () => {
@@ -276,4 +353,9 @@ th, td {
   flex: 1;
 }
 
+.expire-filter {
+  width: 260px;
+  flex-shrink: 0;
+}
+
 </style>

File diff suppressed because it is too large
+ 4 - 0
word/2026-09-11-申勤数字化平台-流程协同.html


Some files were not shown because too many files changed in this diff