zhangwencui
9 天以前 67aebf4dcaa249d99893a88685ea273cc46606f4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
<template>
  <div v-loading="loading" class="execution-panel">
    <el-tabs v-model="activeTab" @tab-change="handleTabChange">
      <el-tab-pane v-if="!singleTab && data.sourceBusiness?.id" label="来源业务" name="business">
        <el-descriptions :column="3" border>
          <el-descriptions-item label="业务编号">{{ data.sourceBusiness.id }}</el-descriptions-item>
          <el-descriptions-item label="项目名称">{{ data.sourceBusiness.projectName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="客户名称">{{ data.sourceBusiness.customerName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="业务员">{{ data.sourceBusiness.salesman || '-' }}</el-descriptions-item>
          <el-descriptions-item label="录入日期">{{ data.sourceBusiness.entryDate || '-' }}</el-descriptions-item>
          <el-descriptions-item label="审批状态">{{ ({ 0:'草稿',1:'待审批',2:'已通过',3:'已退回' })[data.sourceBusiness.approvalStatus] || '-' }}</el-descriptions-item>
          <el-descriptions-item label="业务备注" :span="3">{{ data.sourceBusiness.remarks || '-' }}</el-descriptions-item>
          <el-descriptions-item label="业务附件" :span="3">
            <template v-if="data.sourceBusiness.attachmentList?.length">
              <el-button v-for="(file,index) in data.sourceBusiness.attachmentList" :key="index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index+1}` }}</el-button>
            </template>
            <span v-else>{{ data.sourceBusiness.attachmentMaterials || '无' }}</span>
          </el-descriptions-item>
        </el-descriptions>
      </el-tab-pane>
 
      <el-tab-pane v-if="!singleTab || singleTab === 'modules'" :label="readonly ? '模块信息' : '模块管理'" name="modules">
        <div class="tab-actions">
          <span class="hint">模块占项目工作量合计:{{ totalModuleWeight }}%</span>
          <el-button v-if="!readonly" v-hasPermi="['project:module:edit']" type="primary" @click="openModuleBatch">{{ isStarted ? '管理执行人' : '编辑模块' }}</el-button>
        </div>
        <el-table :data="data.modules" border>
          <el-table-column prop="moduleName" label="模块名称" min-width="160" />
          <el-table-column prop="plannedStartDate" label="预计开始" width="120" />
          <el-table-column prop="summary" label="模块概要" min-width="220" show-overflow-tooltip />
          <el-table-column prop="projectWeight" label="项目工作量占比" width="150" align="center">
            <template #default="{ row }">{{ row.projectWeight }}%</template>
          </el-table-column>
          <el-table-column prop="currentProgress" label="当前进度" width="120" align="center">
            <template #default="{ row }"><el-progress :percentage="Number(row.currentProgress || 0)" /></template>
          </el-table-column>
          <el-table-column label="模块执行人" min-width="230">
            <template #default="{ row }">
              <el-tag v-for="member in row.members || []" :key="member.id" class="member-tag">
                {{ member.userName }}
              </el-tag>
            </template>
          </el-table-column>
        </el-table>
      </el-tab-pane>
 
      <el-tab-pane v-if="isStarted && (!singleTab || singleTab === 'follow')" label="项目跟进" name="follow">
        <div class="tab-actions">
          <div class="hint">
            跟进周期:{{ followCycleLabel }};
            <span v-if="data.followWindow?.dueDate">本次应跟进日 {{ data.followWindow.dueDate }},可填写 {{ data.followWindow.windowStart }} 至 {{ data.followWindow.windowEnd }}</span>
            <span v-else>首次跟进可直接填写</span>
          </div>
          <el-button v-if="!readonly && canManageFollow" v-hasPermi="['project:follow:add']" type="primary" @click="openFollow()">填写跟进</el-button>
        </div>
        <el-table :data="data.followUps" border>
          <el-table-column prop="followDate" label="跟进日期" width="130" />
          <el-table-column prop="createUserName" label="项目负责人" width="140" />
          <el-table-column prop="detail" label="跟进详情" min-width="300" show-overflow-tooltip />
          <el-table-column label="附件" min-width="190">
            <template #default="{ row }">
              <div v-if="row.attachmentList?.length" class="file-cell"><el-button v-for="(file,index) in row.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
              <span v-else class="empty-file">无附件</span>
            </template>
          </el-table-column>
          <el-table-column v-if="!readonly && canManageFollow" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:follow:add']" link type="primary" @click="openFollow(row)">编辑</el-button><el-button v-hasPermi="['project:follow:remove']" link type="danger" @click="removeRecord('follow', row)">删除</el-button></template></el-table-column>
        </el-table>
      </el-tab-pane>
 
      <el-tab-pane v-if="isStarted && (!singleTab || singleTab === 'reports')" label="项目汇报" name="reports">
        <div class="report-workbench">
          <aside class="report-module-nav">
            <div class="report-nav-title">汇报范围</div>
            <button type="button" class="report-nav-item" :class="{ active: reportModuleFilter === 'all' }" @click="selectReportModule('all')">
              <span class="report-nav-name">全部(整个项目)</span>
              <span class="report-nav-meta">共 {{ data.reports.length }} 条汇报</span>
            </button>
            <button v-for="module in data.modules" :key="module.id" type="button" class="report-nav-item" :class="{ active: reportModuleFilter === String(module.id) }" @click="selectReportModule(String(module.id))">
              <span class="report-nav-name">{{ module.moduleName }}</span>
              <span class="report-nav-meta">进度 {{ Number(module.currentProgress || 0) }}% · {{ moduleReportCount(module.id) }} 条汇报</span>
            </button>
          </aside>
          <section class="report-content">
            <div class="tab-actions">
              <span class="report-stat-summary">{{ reportStatisticsSummary }}</span>
              <el-button v-if="!readonly" v-hasPermi="['project:report:add']" type="primary" :disabled="!eligibleReportModules.length" @click="openReport()">新增汇报</el-button>
            </div>
            <div class="report-layout" :class="{ 'report-only': !readonly }">
              <div v-if="readonly" ref="chartRef" class="chart" />
              <el-table :data="filteredReports" border class="report-table">
                <el-table-column prop="moduleName" label="项目模块" min-width="130" />
                <el-table-column label="汇报人员比重" min-width="210"><template #default="{ row }"><el-tag v-for="member in row.reporterShareList || []" :key="member.userId" class="member-tag">{{ member.userName }} {{ member.moduleShare }}%</el-tag></template></el-table-column>
                <el-table-column prop="incrementProgress" label="本次进度" width="100"><template #default="{ row }">{{ row.incrementProgress ?? row.progress }}%</template></el-table-column>
                <el-table-column prop="reportDate" label="时间" width="120" />
                <el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
                <el-table-column label="文件" min-width="190">
                  <template #default="{ row }">
                    <div v-if="row.attachmentList?.length" class="file-cell"><el-button v-for="(file,index) in row.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
                    <span v-else class="empty-file">无附件</span>
                  </template>
                </el-table-column>
                <el-table-column v-if="!readonly" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-if="canEditReport(row)" v-hasPermi="['project:report:add']" link type="primary" @click="openReport(row)">编辑</el-button><el-button v-if="canEditReport(row)" v-hasPermi="['project:report:remove']" link type="danger" @click="removeRecord('report', row)">删除</el-button></template></el-table-column>
              </el-table>
            </div>
          </section>
        </div>
      </el-tab-pane>
 
      <el-tab-pane v-if="!singleTab || singleTab === 'materials'" label="项目资料" name="materials">
        <div class="tab-actions">
          <span class="hint">项目整体资料库,与业务附件和单次汇报附件分开保存</span>
          <el-button v-if="!readonly" v-hasPermi="['project:material:add']" type="primary" @click="openMaterial()">上传资料</el-button>
        </div>
        <el-table :data="data.materials" border>
          <el-table-column prop="materialName" label="资料名称" min-width="220" />
          <el-table-column prop="materialType" label="资料类型" width="130" />
          <el-table-column prop="createUserName" label="维护人" width="130" />
          <el-table-column prop="createTime" label="上传时间" width="180" />
          <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
          <el-table-column label="文件" min-width="200">
            <template #default="{ row }">
              <el-button v-for="(file, index) in row.attachmentList || []" :key="file.id || index" link type="primary" @click="download(file)">
                {{ file.name || `附件${index + 1}` }}
              </el-button>
            </template>
          </el-table-column>
          <el-table-column v-if="!readonly" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:material:add']" link type="primary" @click="openMaterial(row)">编辑</el-button><el-button v-hasPermi="['project:material:remove']" link type="danger" @click="removeRecord('material', row)">删除</el-button></template></el-table-column>
        </el-table>
      </el-tab-pane>
 
      <template v-if="!singleTab">
        <el-tab-pane v-for="stage in visibleProcessStages" :key="stage.code" :label="stage.label" :name="stage.code">
          <el-empty v-if="!stageRecords(stage.code).length" description="暂无阶段办理记录" />
          <el-timeline v-else class="phase-record-timeline">
            <el-timeline-item v-for="record in stageRecords(stage.code)" :key="`${stage.code}-${record.id || record.actionTime}`" :timestamp="formatActionTime(record.actionTime)" placement="top" :type="phaseRecordType(record.actionType)">
              <el-card shadow="never" class="phase-record-card">
                <template #header>
                  <div class="phase-record-head"><div><strong>{{ record.actionLabel || stage.phaseName }}</strong><el-tag size="small" :type="phaseRecordType(record.actionType)">{{ recordStatusLabel(record) }}</el-tag></div><span>操作人:{{ record.operatorName || '-' }}</span></div>
                </template>
                <el-descriptions v-if="phaseFieldRows(record).length" :column="2" border>
                  <el-descriptions-item v-for="field in phaseFieldRows(record)" :key="field.label" :label="field.label">{{ field.value }}</el-descriptions-item>
                </el-descriptions>
                <div v-if="record.remark" class="phase-record-remark"><b>办理说明:</b>{{ record.remark }}</div>
                <div v-if="record.attachmentList?.length" class="phase-record-files"><b>相关附件:</b><el-button v-for="(file,index) in record.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
              </el-card>
            </el-timeline-item>
          </el-timeline>
        </el-tab-pane>
      </template>
    </el-tabs>
 
    <el-dialog v-model="moduleVisible" :title="isStarted ? '管理模块执行人' : '编辑项目模块'" width="1100px" top="4vh" destroy-on-close>
      <el-alert :title="isStarted ? '项目开始后模块信息不可修改,仅可新增执行人;已有本模块工作占比的执行人不能删除。' : '所有模块一次性保存,工作量占比合计必须为100%后项目才能开始。'" type="info" :closable="false" class="dialog-tip" />
      <div class="module-total" :class="{ invalid: Number(moduleDraftTotal) !== 100 }">模块工作量合计:{{ moduleDraftTotal }}%,剩余可分配:{{ moduleDraftRemaining }}%</div>
      <div v-for="(module, moduleIndex) in moduleDrafts" :key="module.localKey" class="module-batch-card">
        <div class="module-batch-title">
          <strong>模块 {{ moduleIndex + 1 }}</strong>
          <span v-if="module.locked" class="locked-tip">项目已开始,模块信息不可修改</span>
          <el-button v-if="!isStarted && !module.locked" link type="danger" @click="removeModuleDraft(moduleIndex)">移除模块</el-button>
        </div>
        <div class="module-grid">
          <el-form-item label="模块名称" required><el-input v-model="module.moduleName" :disabled="module.locked" /></el-form-item>
          <el-form-item label="预计开始" required><el-date-picker v-model="module.plannedStartDate" :disabled="module.locked" :disabled-date="moduleDateDisabled" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="工作量占比" required><el-input-number v-model="module.projectWeight" :disabled="module.locked" :min="0.01" :max="draftWeightMax(moduleIndex)" :precision="2" /><span class="unit">%</span><div class="percent-hint">其他模块 {{ otherDraftWeight(moduleIndex) }}%,本模块最多 {{ draftWeightMax(moduleIndex) }}%</div></el-form-item>
          <el-form-item label="模块概要" required class="summary-item"><el-input v-model="module.summary" :disabled="module.locked" type="textarea" :rows="2" maxlength="1000" show-word-limit /></el-form-item>
          <el-form-item label="模块执行人" required class="members-item">
            <div class="member-editor">
              <div v-for="(member, memberIndex) in module.members" :key="memberIndex" class="member-row">
                <el-select v-model="member.userId" :disabled="member.hasReportedShare" filterable placeholder="请选择执行人" @change="id => syncMemberName(member, id)"><el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" /></el-select>
                <el-tag v-if="member.hasReportedShare" type="warning">已完成模块占比:{{ member.completedModuleShare }}%</el-tag>
                <el-button v-else link type="danger" @click="module.members.splice(memberIndex, 1)">删除</el-button>
              </div>
              <el-button link type="primary" @click="module.members.push({ userId:null, userName:'', hasReportedShare:false, completedModuleShare:0 })">+ 添加执行人</el-button>
            </div>
          </el-form-item>
        </div>
      </div>
      <el-button v-if="!isStarted" type="primary" plain @click="addModuleDraft">+ 添加模块</el-button>
      <template #footer><el-button @click="moduleVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitModuleBatch">保存</el-button></template>
    </el-dialog>
 
    <el-dialog v-model="followVisible" :title="followForm.id ? '编辑项目跟进' : '填写项目跟进'" width="650px" destroy-on-close>
      <el-form ref="followFormRef" :model="followForm" :rules="followRules" label-width="90px">
        <el-form-item label="跟进日期" prop="followDate"><el-date-picker v-model="followForm.followDate" :disabled="!!followForm.id" :disabled-date="date => dayjs(date).isAfter(dayjs(), 'day')" type="date" value-format="YYYY-MM-DD" style="width: 100%" /><div v-if="followForm.id" class="percent-hint">历史跟进日期不可修改,避免破坏后续跟进周期。</div></el-form-item>
        <el-form-item label="跟进详情" prop="detail"><el-input v-model="followForm.detail" type="textarea" :rows="5" maxlength="2000" show-word-limit /></el-form-item>
        <el-form-item label="附件"><AttachmentUploadFile v-model:file-list="followFiles" :limit="10" /></el-form-item>
      </el-form>
      <template #footer><el-button @click="followVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitFollow">提交</el-button></template>
    </el-dialog>
 
    <el-dialog v-model="reportVisible" title="进度汇报" width="680px" destroy-on-close>
      <el-form ref="reportFormRef" :model="reportForm" :rules="reportRules" label-width="100px">
        <el-form-item label="所属项目"><el-input :model-value="projectInfo?.title" disabled /></el-form-item>
        <el-form-item label="项目模块" prop="moduleId"><el-select v-model="reportForm.moduleId" :disabled="!!reportForm.id" style="width: 100%" @change="changeReportModule"><el-option v-for="item in reportModuleOptions" :key="item.id" :label="item.moduleName" :value="item.id" /></el-select><div class="percent-hint">仅展示当前登录人参与执行且尚未完成的模块;已有汇报不能更换模块。</div></el-form-item>
        <el-form-item label="汇报人员比重" required>
          <div class="member-editor">
            <div v-for="(member,index) in reportForm.reporterShares" :key="index" class="reporter-row">
              <el-select v-model="member.userId" filterable placeholder="请选择人员" @change="id => syncMemberName(member,id)"><el-option v-for="user in moduleReporterOptions" :key="user.value" :label="user.label" :value="user.value" /></el-select>
              <el-input-number v-model="member.moduleShare" :min="0" :max="reporterShareLimit(index)" :precision="2" />
              <span>%</span>
              <el-button link type="danger" @click="reportForm.reporterShares.splice(index,1)">删除</el-button>
            </div>
            <el-button link type="primary" @click="reportForm.reporterShares.push({userId:null,userName:'',moduleShare:0})">+ 添加人员</el-button>
            <div class="share-total">本次人员比重合计:{{ reportShareTotal }}%,剩余可分配:{{ reportShareRemaining }}%。每人的上限会随其他人员占比自动调整。</div>
          </div>
        </el-form-item>
        <el-form-item label="本次进度" prop="incrementProgress"><el-input-number v-model="reportForm.incrementProgress" :min="0.01" :max="reportIncrementMax" :precision="2" /><span class="unit">%</span><span class="current-progress-hint">{{ reportProgressHint }}</span></el-form-item>
        <el-form-item label="汇报时间" prop="reportDate"><el-date-picker v-model="reportForm.reportDate" :disabled="!!reportForm.id" :disabled-date="date => dayjs(date).isAfter(dayjs(), 'day')" type="date" value-format="YYYY-MM-DD" style="width: 100%" /><div v-if="reportForm.id" class="percent-hint">编辑已有汇报时不允许调整时间,以保证历史进度顺序正确。</div></el-form-item>
        <el-form-item label="备注"><el-input v-model="reportForm.remark" type="textarea" :rows="3" maxlength="2000" show-word-limit /></el-form-item>
        <el-form-item label="文件"><AttachmentUploadFile v-model:file-list="reportFiles" :limit="10" /></el-form-item>
      </el-form>
      <template #footer><el-button @click="reportVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitReport">{{ reportForm.id ? '保存修改' : '提交汇报' }}</el-button></template>
    </el-dialog>
 
    <el-dialog v-model="materialVisible" :title="materialForm.id ? '编辑项目资料' : '上传项目资料'" width="650px" destroy-on-close>
      <el-form ref="materialFormRef" :model="materialForm" :rules="materialRules" label-width="90px">
        <el-form-item label="资料名称" prop="materialName"><el-input v-model="materialForm.materialName" /></el-form-item>
        <el-form-item label="资料类型"><el-select v-model="materialForm.materialType" allow-create filterable clearable style="width: 100%"><el-option v-for="type in ['立项资料','开发资料','申报资料','结果资料','验收资料','其他']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
        <el-form-item label="文件" required><AttachmentUploadFile v-model:file-list="materialFiles" :limit="20" /></el-form-item>
        <el-form-item label="备注"><el-input v-model="materialForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
      </el-form>
      <template #footer><el-button @click="materialVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitMaterial">保存</el-button></template>
    </el-dialog>
  </div>
</template>
 
<script setup>
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import { userListAll } from '@/api/publicApi'
import {
  deleteProjectFollowUp, deleteProjectMaterial, deleteProjectProgressReport,
  getProjectWorkflow, saveProjectFollowUp, saveProjectMaterial, saveProjectModulesBatch, saveProjectProgressReport
} from '@/api/projectManagement/workflow'
import useUserStore from '@/store/modules/user'
 
const props = defineProps({
  projectId: [String, Number],
  projectInfo: { type: Object, default: () => ({}) },
  readonly: { type: Boolean, default: false },
  singleTab: { type: String, default: '' }
})
const emit = defineEmits(['project-updated'])
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const loading = ref(false)
const saving = ref(false)
const activeTab = ref(props.singleTab || 'modules')
const reportModuleFilter = ref('all')
const userOptions = ref([])
const data = reactive({ modules: [], followUps: [], reports: [], materials: [], phases: [], phaseHistories: [], sourceBusiness: {}, memberStatistics: [], followWindow: {} })
const chartRef = ref()
let chart
 
const today = () => dayjs().format('YYYY-MM-DD')
const totalModuleWeight = computed(() => data.modules.reduce((sum, row) => sum + Number(row.projectWeight || 0), 0).toFixed(2))
const reportShareTotal = computed(() => reportForm.reporterShares.reduce((sum, row) => sum + Number(row.moduleShare || 0), 0).toFixed(2))
const reportShareRemaining = computed(() => Math.max(0, 100 - Number(reportShareTotal.value)).toFixed(2))
const followCycleLabel = computed(() => ({ MONTH: '每月', QUARTER: '每季度', YEAR: '每年' }[props.projectInfo?.followCycle] || props.projectInfo?.followCycle || '每月'))
const isStarted = computed(() => !!props.projectInfo?.currentPhase && props.projectInfo.currentPhase !== '未开始')
const canManageFollow = computed(() => String(props.projectInfo?.projectLeaderId ?? '') === String(userStore.id ?? ''))
const memberReportModules = computed(() => data.modules.filter(module =>
  (module.members || []).some(member => String(member.userId) === String(userStore.id))
))
const eligibleReportModules = computed(() => memberReportModules.value.filter(module => Number(module.currentProgress || 0) < 100))
const reportModuleOptions = computed(() => reportForm.id ? memberReportModules.value : eligibleReportModules.value)
const selectedReportModule = computed(() => data.modules.find(module => String(module.id) === String(reportForm.moduleId)))
const reportBaseProgress = computed(() => Number(reportForm.id ? reportForm.previousProgress : selectedReportModule.value?.currentProgress || 0))
const reportIncrementMax = computed(() => {
  if (!reportForm.id) return Math.max(0.01, Number((100 - reportBaseProgress.value).toFixed(2)))
  const current = Number(selectedReportModule.value?.currentProgress || 0)
  return Math.max(0.01, Number((Number(reportForm.originalIncrementProgress || 0) + 100 - current).toFixed(2)))
})
const reportProgressHint = computed(() => {
  const current = Number(selectedReportModule.value?.currentProgress || 0).toFixed(2)
  if (reportForm.id) return `该次汇报前为 ${reportBaseProgress.value.toFixed(2)}%,模块当前为 ${current}%`
  return `模块当前进度 ${current}%,本次最多可汇报 ${reportIncrementMax.value}%`
})
const filteredReports = computed(() => reportModuleFilter.value === 'all'
  ? data.reports
  : data.reports.filter(report => String(report.moduleId) === reportModuleFilter.value))
const filteredMemberStatistics = computed(() => {
  if (reportModuleFilter.value === 'all') return data.memberStatistics || []
  const totals = new Map()
  for (const report of filteredReports.value) {
    const incrementProgress = Number(report.incrementProgress ?? report.progress ?? 0)
    for (const member of report.reporterShareList || []) {
      const userId = String(member.userId)
      const current = totals.get(userId) || { userId: member.userId, userName: member.userName || '未命名', value: 0 }
      current.value += incrementProgress * Number(member.moduleShare || 0) / 100
      totals.set(userId, current)
    }
  }
  return [...totals.values()]
    .map(item => ({ ...item, value: Number(item.value.toFixed(2)) }))
    .sort((a, b) => b.value - a.value)
})
const reportStatisticsSummary = computed(() => {
  const prefix = reportModuleFilter.value === 'all' ? '项目占比统计' : '模块占比统计'
  if (!filteredMemberStatistics.value.length) return `${prefix}:暂无数据`
  return `${prefix}:${filteredMemberStatistics.value.map(item => `${item.userName} ${item.value}%`).join(';')}`
})
const processStages = [
  { code: 'APPLICATION', label: '申报记录', phaseName: '申报', rank: 3 },
  { code: 'RESULT', label: '申报结果', phaseName: '申报结果', rank: 4 },
  { code: 'ACCEPTANCE', label: '验收记录', phaseName: '验收', rank: 5 }
]
const currentPhaseRank = computed(() => ({ 未开始: 0, 立项: 1, 开发: 2, 申报: 3, 申报结果: 4, 验收: 5, 已完成: 6 })[props.projectInfo?.currentPhase] || 0)
const visibleProcessStages = computed(() => processStages.filter(stage => currentPhaseRank.value >= stage.rank))
const moduleReporterOptions = computed(() => {
  const members = selectedReportModule.value?.members || []
  return members.map(member => ({
    value: member.userId,
    label: member.userName || userOptions.value.find(user => String(user.value) === String(member.userId))?.label || String(member.userId)
  }))
})
 
const moduleVisible = ref(false)
const moduleDrafts = ref([])
let draftSequence = 0
const moduleDraftTotal = computed(() => moduleDrafts.value.reduce((sum, module) => sum + Number(module.projectWeight || 0), 0).toFixed(2))
const moduleDraftRemaining = computed(() => (100 - Number(moduleDraftTotal.value)).toFixed(2))
const followVisible = ref(false)
const followFormRef = ref()
const followForm = reactive({ id: null, followDate: today(), detail: '' })
const followFiles = ref([])
const followRules = { followDate: [{ required: true, message: '请选择跟进日期', trigger: 'change' }], detail: [{ required: true, message: '请输入跟进详情', trigger: 'blur' }] }
const reportVisible = ref(false)
const reportFormRef = ref()
const reportForm = reactive({ id: null, moduleId: null, reporterShares: [], incrementProgress: 0, originalIncrementProgress: 0, previousProgress: 0, reportDate: today(), remark: '' })
const reportFiles = ref([])
const reportRules = { moduleId: [{ required: true, message: '请选择项目模块', trigger: 'change' }], reportDate: [{ required: true, message: '请选择汇报时间', trigger: 'change' }] }
const materialVisible = ref(false)
const materialFormRef = ref()
const materialForm = reactive({ id: null, materialName: '', materialType: '', remark: '' })
const materialFiles = ref([])
const materialRules = { materialName: [{ required: true, message: '请输入资料名称', trigger: 'blur' }] }
async function loadUsers() {
  const res = await userListAll()
  const list = res?.data || res?.rows || res || []
  userOptions.value = (Array.isArray(list) ? list : []).map(user => ({ label: user.nickName || user.userName || user.name, value: user.userId || user.id }))
}
 
async function loadData() {
  if (!props.projectId) return
  loading.value = true
  try {
    const res = await getProjectWorkflow(props.projectId)
    Object.assign(data, res?.data || {})
    await nextTick()
    if (activeTab.value === 'reports') renderChart()
  } finally { loading.value = false }
}
 
function newModuleDraft(row = {}) {
  return {
    id: row.id || null, localKey: row.id ? `module-${row.id}` : `new-${++draftSequence}`,
    moduleName: row.moduleName || '', plannedStartDate: row.plannedStartDate || '', summary: row.summary || '',
    projectWeight: Number(row.projectWeight || 0),
    members: (row.members || []).map(member => {
      const reportStats = memberReportedContribution(row, member.userId)
      return { userId: member.userId, userName: member.userName, ...reportStats }
    }),
    locked: isStarted.value
  }
}
 
function memberReportedContribution(module, userId) {
  let hasReportedShare = false
  const completedModuleShare = data.reports
    .filter(report => String(report.moduleId) === String(module.id))
    .reduce((total, report) => {
      const memberShare = (report.reporterShareList || [])
        .filter(member => String(member.userId) === String(userId))
        .reduce((sum, member) => sum + Number(member.moduleShare || 0), 0)
      if (memberShare > 0) hasReportedShare = true
      const incrementProgress = Number(report.incrementProgress ?? report.progress ?? 0)
      return total + incrementProgress * memberShare / 100
    }, 0)
  return { hasReportedShare, completedModuleShare: Number(completedModuleShare.toFixed(2)) }
}
 
function openModuleBatch() {
  moduleDrafts.value = data.modules.map(newModuleDraft)
  if (!moduleDrafts.value.length) addModuleDraft()
  moduleVisible.value = true
}
 
function addModuleDraft() { moduleDrafts.value.push(newModuleDraft({ members: [{ userId: null, userName: '', hasReportedShare: false, completedModuleShare: 0 }] })) }
function removeModuleDraft(index) { moduleDrafts.value.splice(index, 1) }
function otherDraftWeight(index) { return moduleDrafts.value.reduce((sum, module, i) => i === index ? sum : sum + Number(module.projectWeight || 0), 0).toFixed(2) }
function draftWeightMax(index) { return Math.max(0.01, Number((100 - Number(otherDraftWeight(index))).toFixed(2))) }
 
function syncMemberName(member, id) { member.userName = userOptions.value.find(user => String(user.value) === String(id))?.label || '' }
 
async function submitModuleBatch() {
  if (!moduleDrafts.value.length) return ElMessage.warning('请至少添加一个项目模块')
  if (moduleDrafts.value.some(module => !module.moduleName?.trim() || !module.plannedStartDate || !module.summary?.trim())) return ElMessage.warning('请完整填写所有模块的名称、预计开始时间和模块概要')
  if (Number(moduleDraftTotal.value) !== 100) return ElMessage.warning('所有模块的项目工作量占比合计必须为100%')
  for (const module of moduleDrafts.value) {
    if (!module.members.length || module.members.some(member => !member.userId)) return ElMessage.warning(`请为模块“${module.moduleName}”至少选择一名执行人`)
    if (new Set(module.members.map(member => String(member.userId))).size !== module.members.length) return ElMessage.warning(`模块“${module.moduleName}”的执行人不能重复`)
  }
  saving.value = true
  try {
    await saveProjectModulesBatch({ projectId: props.projectId, modules: moduleDrafts.value })
    ElMessage.success('全部模块保存成功'); moduleVisible.value = false; await loadData()
  } finally { saving.value = false }
}
 
function openFollow(row) {
  Object.assign(followForm, row
    ? { id: row.id, followDate: row.followDate, detail: row.detail || '' }
    : { id: null, followDate: today(), detail: '' })
  followFiles.value = row ? [...(row.attachmentList || [])] : []
  followVisible.value = true
}
function normalizeFiles(list) { return list.map(file => ({ id: file.id ?? file.fileId ?? file.rawData?.id, name: file.name ?? file.fileName ?? file.rawData?.originalFilename, url: file.url ?? file.fileUrl ?? file.rawData?.url })).filter(file => file.id || file.url) }
 
async function submitFollow() {
  await followFormRef.value?.validate(); saving.value = true
  try { await saveProjectFollowUp({ ...followForm, projectId: props.projectId, attachments: normalizeFiles(followFiles.value) }); ElMessage.success('跟进已保存'); followVisible.value = false; await loadData() }
  finally { saving.value = false }
}
 
function openReport(row) {
  if (row) {
    if (!memberReportModules.value.some(module => String(module.id) === String(row.moduleId))) return ElMessage.warning('只有该模块执行人才能编辑进度汇报')
    const incrementProgress = Number(row.incrementProgress ?? row.progress ?? 0)
    Object.assign(reportForm, {
      id: row.id, moduleId: row.moduleId,
      reporterShares: (row.reporterShareList || []).map(member => ({ ...member })),
      incrementProgress, originalIncrementProgress: incrementProgress,
      previousProgress: Number(row.previousProgress || 0), reportDate: row.reportDate, remark: row.remark || ''
    })
    reportFiles.value = [...(row.attachmentList || [])]
    reportVisible.value = true
    return
  }
  if (!eligibleReportModules.value.length) return ElMessage.warning('当前没有可汇报的未完成模块,或您不是模块执行人')
  const userId = userStore.id
  const userName = userOptions.value.find(user => String(user.value) === String(userId))?.label || userStore.name || ''
  const module = reportModuleFilter.value === 'all'
    ? null
    : eligibleReportModules.value.find(item => String(item.id) === reportModuleFilter.value)
  Object.assign(reportForm, {
    id: null,
    moduleId: module?.id ?? null,
    reporterShares: module && userId ? [{ userId, userName, moduleShare: 100 }] : [],
    incrementProgress: 0,
    originalIncrementProgress: 0,
    previousProgress: Number(module?.currentProgress || 0),
    reportDate: today(),
    remark: ''
  })
  reportFiles.value = []; reportVisible.value = true
}
 
function changeReportModule(moduleId) {
  const module = data.modules.find(item => String(item.id) === String(moduleId))
  const userId = userStore.id
  const userName = module?.members?.find(member => String(member.userId) === String(userId))?.userName || userStore.name || ''
  reportForm.incrementProgress = 0
  reportForm.previousProgress = Number(module?.currentProgress || 0)
  reportForm.reporterShares = userId ? [{ userId, userName, moduleShare: 100 }] : []
}
 
function canEditReport(row) {
  return (row.reporterIdList || []).some(userId => String(userId) === String(userStore.id))
}
 
function moduleDateDisabled(date) {
  const start = props.projectInfo?.planStartTime
  const end = props.projectInfo?.planEndTime
  return (start && dayjs(date).isBefore(dayjs(start), 'day')) || (end && dayjs(date).isAfter(dayjs(end), 'day'))
}
 
function reporterShareLimit(index) {
  const usedByOthers = reportForm.reporterShares.reduce((sum, member, memberIndex) =>
    memberIndex === index ? sum : sum + Number(member.moduleShare || 0), 0)
  return Math.max(0, Number((100 - usedByOthers).toFixed(2)))
}
 
async function submitReport() {
  await reportFormRef.value?.validate()
  if (!reportForm.reporterShares.length || reportForm.reporterShares.some(member => !member.userId)) return ElMessage.warning('请至少选择一名汇报人员')
  if (new Set(reportForm.reporterShares.map(member => String(member.userId))).size !== reportForm.reporterShares.length) return ElMessage.warning('汇报人员不能重复')
  if (Number(reportShareTotal.value) !== 100) return ElMessage.warning('本次汇报人员比重合计必须为100%')
  if (Number(reportForm.incrementProgress || 0) <= 0) return ElMessage.warning('本次进度必须大于0')
  saving.value = true
  try {
    await saveProjectProgressReport({ ...reportForm, projectId: props.projectId, attachments: normalizeFiles(reportFiles.value) })
    ElMessage.success(reportForm.id ? '汇报修改已保存' : '汇报已提交')
    reportVisible.value = false
    await loadData()
    emit('project-updated')
  }
  finally { saving.value = false }
}
 
function openMaterial(row = null) {
  Object.assign(materialForm, row
    ? { id: row.id, materialName: row.materialName || '', materialType: row.materialType || '', remark: row.remark || '' }
    : { id: null, materialName: '', materialType: '', remark: '' })
  materialFiles.value = row ? [...(row.attachmentList || [])] : []
  materialVisible.value = true
}
async function submitMaterial() {
  await materialFormRef.value?.validate()
  if (!materialFiles.value.length) return ElMessage.warning('请上传至少一个文件')
  saving.value = true
  try { await saveProjectMaterial({ ...materialForm, projectId: props.projectId, attachments: normalizeFiles(materialFiles.value) }); ElMessage.success('资料已保存'); materialVisible.value = false; await loadData() }
  finally { saving.value = false }
}
 
async function removeRecord(type, row) {
  await ElMessageBox.confirm('确认删除这条记录吗?', '提示', { type: 'warning' })
  const api = { follow: deleteProjectFollowUp, report: deleteProjectProgressReport, material: deleteProjectMaterial }[type]
  await api(row.id); ElMessage.success('删除成功'); await loadData()
  if (type === 'report') emit('project-updated')
}
 
function download(file) { if (!file?.url) return ElMessage.warning('附件暂无下载地址'); proxy.$download.byUrl(file.url, file.name || file.fileName) }
 
function renderChart() {
  if (!chartRef.value) return
  chart ||= echarts.init(chartRef.value)
  const source = filteredMemberStatistics.value
  const isAll = reportModuleFilter.value === 'all'
  chart.setOption({
    title: { text: isAll ? '成员项目占比' : '成员模块占比', left: 'center', textStyle: { fontSize: 15 } },
    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
    legend: { bottom: 0 },
    series: [{ type: 'pie', radius: ['38%', '68%'], center: ['50%', '48%'], data: source.map(item => ({ name: item.userName || '未命名', value: Number(item.value || 0) })), label: { formatter: '{b}\n{c}' } }]
  })
}
 
function handleTabChange(name) { if (name === 'reports') nextTick(renderChart) }
function selectReportModule(moduleId) { reportModuleFilter.value = moduleId; nextTick(renderChart) }
function moduleReportCount(moduleId) { return data.reports.filter(report => String(report.moduleId) === String(moduleId)).length }
function stageRecords(phaseCode) {
  const histories = (data.phaseHistories || []).filter(record => record.phaseCode === phaseCode)
  if (histories.length) return histories
  const snapshot = (data.phases || []).find(record => record.phaseCode === phaseCode)
  if (!snapshot || snapshot.status === 'NOT_STARTED') return []
  return [{ ...snapshot, actionLabel: snapshot.phaseName, actionType: snapshot.status === 'COMPLETED' ? 'PASS' : 'ENTER' }]
}
function formatActionTime(time) { return time ? String(time).replace('T', ' ') : '时间未记录' }
function phaseRecordType(type) { return ['RECTIFY', 'SUPPLEMENT'].includes(type) ? 'warning' : type === 'REJECTED' ? 'danger' : type === 'PASS' ? 'success' : 'primary' }
function recordStatusLabel(record) { return record.actionType === 'RECTIFY' ? '需要整改' : record.actionType === 'SUPPLEMENT' ? '需要补充' : record.actionType === 'REJECTED' ? '未通过' : record.actionType === 'PASS' ? '已通过' : '已办理' }
function phaseFieldRows(record) {
  const form = record.formData || {}
  const labels = {
    applicationDate: '申报日期', applicationType: '申报类型', applicationName: '申报名称', expectedFeedbackDate: '预计反馈日期', description: '申报说明',
    resultDate: '结果日期', resultStatus: '申报结果', resultDescription: '结果说明', acceptancePlanDate: '计划验收日期',
    acceptor: '验收人/客户', acceptanceStandard: '验收标准', acceptanceDate: '验收日期', acceptanceResult: '验收结果', acceptanceDescription: '验收说明'
  }
  const enumLabels = { PASS: '通过', SUPPLEMENT: '需要补充', REJECTED: '未通过', RECTIFY: '需要整改' }
  return Object.entries(labels).filter(([key]) => form[key] !== null && form[key] !== undefined && form[key] !== '').map(([key, label]) => ({ label, value: enumLabels[form[key]] || form[key] }))
}
function resizeChart() { chart?.resize() }
onMounted(async () => { await Promise.all([loadUsers(), loadData()]); window.addEventListener('resize', resizeChart) })
onBeforeUnmount(() => { window.removeEventListener('resize', resizeChart); chart?.dispose() })
defineExpose({ refresh: loadData })
</script>
 
<style scoped>
.execution-panel { min-height: 260px; }
.tab-actions { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 14px; }
.hint { color: #606266; font-size: 13px; }
.report-stat-summary { color: #606266; font-size: 13px; line-height: 1.7; }
.report-workbench { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 16px; align-items: start; }
.report-module-nav { padding: 12px; border: 1px solid #e4e7ed; border-radius: 10px; background: #f8fafc; }
.report-nav-title { padding: 4px 10px 10px; color: #303133; font-size: 14px; font-weight: 600; }
.report-nav-item { width: 100%; margin: 0 0 8px; padding: 11px 12px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: #303133; text-align: left; cursor: pointer; transition: all .2s; }
.report-nav-item:last-child { margin-bottom: 0; }
.report-nav-item:hover { border-color: #b8cdf8; background: #eef4ff; }
.report-nav-item.active { border-color: #8fb2f5; background: #e8f1ff; color: #0b46b5; box-shadow: 0 2px 7px rgba(11, 70, 181, .08); }
.report-nav-name, .report-nav-meta { display: block; }
.report-nav-name { overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.report-nav-meta { margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.35; }
.report-nav-item.active .report-nav-meta { color: #5e7fbf; }
.report-content { min-width: 0; }
.member-tag { margin: 2px 6px 2px 0; }
.report-layout { display: grid; grid-template-columns: minmax(300px, 34%) 1fr; gap: 14px; }
.report-layout.report-only { grid-template-columns: 1fr; }
.chart { height: 390px; border: 1px solid #ebeef5; border-radius: 6px; }
.report-table { min-width: 0; }
.member-editor { width: 100%; }
.member-row { display: grid; grid-template-columns: minmax(260px, 1fr) auto 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
.reporter-row { display: grid; grid-template-columns: 1fr 150px 24px 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
.share-total { margin-top: 8px; color: #606266; }
.percent-hint { width: 100%; margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.5; }
.dialog-tip { margin-bottom: 16px; }
.unit { margin-left: 8px; color: #606266; }
.current-progress-hint { margin-left: 14px; color: #909399; font-size: 12px; }
.phase-record-timeline { padding: 14px 12px 0 8px; }.phase-record-card { border-color: #dce6f5; }.phase-record-head { display:flex;align-items:center;justify-content:space-between;gap:16px; }.phase-record-head>div { display:flex;align-items:center;gap:10px; }.phase-record-head>span { color:#909399;font-size:12px; }.phase-record-remark,.phase-record-files { margin-top:14px;color:#606266;font-size:13px;line-height:1.7; }.phase-record-files { display:flex;align-items:center;flex-wrap:wrap;gap:4px; }
.file-cell { display:flex;flex-direction:column;align-items:flex-start;gap:2px; }.file-cell .el-button { max-width:100%;margin-left:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }.empty-file { color:#c0c4cc;font-size:12px; }
.module-total { margin: 0 0 14px; font-weight: 600; color: #067647; }
.module-total.invalid { color: #d97706; }
.module-batch-card { margin-bottom: 14px; padding: 16px 16px 4px; border: 1px solid #dcdfe6; border-radius: 8px; background: #fafcff; }
.module-batch-title { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.module-batch-title .el-button { margin-left: auto; }
.locked-tip { color: #e6a23c; font-size: 12px; }
.module-grid { display: grid; grid-template-columns: 1.2fr 1fr 1.25fr; column-gap: 18px; }
.summary-item, .members-item { grid-column: 1 / -1; }
@media (max-width: 1200px) { .report-layout { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .report-workbench { grid-template-columns: 1fr; } .report-module-nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; } .report-nav-title { grid-column: 1 / -1; } .report-nav-item { margin: 0; } }
</style>