lambo-design-bi 0.0.248 → 0.0.251

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,19 @@
1
1
  <!-- eslint-disable -->
2
2
  <template>
3
3
  <div class="bulletin-widget">
4
- <div class="bulletin-header" v-if="hasTitle">
5
- <div class="bulletin-title">{{ cardTitle }}</div>
6
- <div class="bulletin-subtitle" v-if="subTitle">{{ subTitle }}</div>
4
+ <div class="bulletin-header" v-if="hasTitle || previewUrl">
5
+ <div class="bulletin-title-wrap" v-if="hasTitle">
6
+ <div class="bulletin-title">{{ cardTitle }}</div>
7
+ <div class="bulletin-subtitle" v-if="subTitle">{{ subTitle }}</div>
8
+ </div>
9
+ <div
10
+ v-if="previewUrl"
11
+ class="bulletin-download-btn"
12
+ title="下载简报"
13
+ @click="doDownload">
14
+ <Icon type="md-download" class="btn-icon" />
15
+ <span class="btn-text">下载</span>
16
+ </div>
7
17
  </div>
8
18
 
9
19
  <div
@@ -21,25 +31,19 @@
21
31
  <p>尚未发布简报</p>
22
32
  <p class="hint">该期间/组织暂无已发布的简报成品</p>
23
33
  </div>
24
- <template v-else>
25
- <div
26
- class="bulletin-download-fab"
27
- title="下载简报"
28
- @click="doDownload">
29
- <Icon type="md-download" />
30
- </div>
31
- <lambo-doc-word
32
- :url="previewUrl"
33
- :key="previewUrl"
34
- :report="summary" />
35
- </template>
34
+ <lambo-doc-word
35
+ v-else
36
+ :url="previewUrl"
37
+ :key="previewUrl"
38
+ :report="summary"
39
+ :publish-time="publishTime" />
36
40
  </div>
37
41
  </div>
38
42
  </template>
39
43
  <script>
40
44
  import LamboDocWord from '../../../pub/doc/word/index.vue'
41
45
  import { parseStyleStr } from '../util/css-util'
42
- import { getBulletinSummary, listBulletinPublishDoc } from './api/bulletin'
46
+ import { listBulletinPublishDoc } from './api/bulletin'
43
47
  import { fileGetUrl } from '../api/config'
44
48
 
45
49
  // 统一日志前缀,便于在控制台按关键字过滤简报组件的关键过程
@@ -67,6 +71,24 @@ const PERIOD_KEY_CANDIDATES = {
67
71
  const COM_ID_CANDIDATES = ['comId', 'com_id']
68
72
  const DIST_CT_ID_CANDIDATES = ['distCtId', 'dist_ct_id']
69
73
 
74
+ // 规范化 publishTime 字符串:兼容后端可能返回的多种格式
75
+ // - "2026-07-30 17:14:22" / "2026-07-30T17:14:22" → 原样
76
+ // - "20260730171422" 14 位紧凑 → "2026-07-30 17:14:22"
77
+ // - 其他 → 原样
78
+ function normalizePublishTime(v) {
79
+ if (v == null) return ""
80
+ const s = String(v).trim()
81
+ if (!s) return ""
82
+ // 已经是带分隔符的格式
83
+ if (/[-/]/.test(s)) return s
84
+ // 14 位紧凑:YYYYMMDDHHmmss
85
+ const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(s)
86
+ if (m) {
87
+ return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
88
+ }
89
+ return s
90
+ }
91
+
70
92
  // 从 params 里按候选 key 取首个非空项,返回命中的 { key, value };均未命中返回 null
71
93
  function firstNonBlankEntry(obj, keys) {
72
94
  if (!obj || !Array.isArray(keys)) return null
@@ -101,10 +123,11 @@ export default {
101
123
  previewUrl: '',
102
124
  lastDocId: '',
103
125
  lastFileName: '',
126
+ // 简报生成时间(plat_bulletin_publish_doc.publish_time)
127
+ publishTime: '',
104
128
  // 空态原因:'' 无 / 'invalid' 查询条件不足(校验未过)/ 'empty' 已查询但无成品
105
129
  emptyReason: '',
106
- // 摘要(data 阶段)
107
- summaryLoading: false,
130
+ // 摘要:取自已发布成品行的 sum_json(plat_bulletin_publish_doc.sum_json),缺省为 null
108
131
  summaryData: null
109
132
  }
110
133
  },
@@ -121,8 +144,8 @@ export default {
121
144
  // 右侧摘要数据源:data 阶段拉取的 summaryData,缺省用空骨架
122
145
  summary() {
123
146
  const s = this.summaryData
124
- if (!s || typeof s !== 'object') return { ...EMPTY_SUMMARY, title: this.cardTitle }
125
- return Object.assign({}, EMPTY_SUMMARY, { title: this.cardTitle }, s)
147
+ if (!s || typeof s !== 'object') return { ...EMPTY_SUMMARY, title: '摘要' }
148
+ return Object.assign({}, EMPTY_SUMMARY, s, { title: '摘要' })
126
149
  }
127
150
  },
128
151
  watch: {
@@ -160,6 +183,7 @@ export default {
160
183
  this.lastDocId = ''
161
184
  this.lastFileName = ''
162
185
  this.previewUrl = ''
186
+ this.publishTime = ''
163
187
  this.emptyReason = reason || ''
164
188
  },
165
189
  loadDoc(params) {
@@ -225,11 +249,13 @@ export default {
225
249
  console.log(LOG, 'loadDoc 命中已发布成品,直接渲染', { docId: doc.docId, docName: doc.docName })
226
250
  this.lastDocId = doc.docId
227
251
  this.lastFileName = doc.docName || (this.cardTitle + '.docx')
252
+ // 与右侧摘要 meta 保持统一格式(左侧语义为"简报发布时间":数据源 plat_bulletin_publish_doc.publish_time)
253
+ this.publishTime = doc.publishTime ? `简报发布时间:${normalizePublishTime(doc.publishTime)}` : ''
228
254
  this.previewUrl = fileGetUrl + doc.docId
229
255
  this.emptyReason = ''
230
256
  this.$Message.success({ content: '查询到已发布的简报', duration: 1.8 })
231
- // 命中成品后才拉右侧摘要
232
- this.loadSummary(params)
257
+ // 命中成品:直接用该行的 sum_json 作为右侧摘要,不再另发请求
258
+ this.applySummaryFromDoc(doc)
233
259
  return
234
260
  }
235
261
  // 未命中已发布成品 → 空态,同时清空摘要(不再查摘要)
@@ -264,42 +290,37 @@ export default {
264
290
  console.log(LOG, 'resolvePeriodEntry', { periodType, candidates, entry })
265
291
  return entry
266
292
  },
267
- // data 阶段之二:获取右侧摘要(单接口,后端已聚合四段)
268
- loadSummary(params) {
269
- const bulletinWidgetId = this.widget && this.widget.id
270
- if (!bulletinWidgetId) {
271
- console.warn(LOG, 'loadSummary 中止:简报id为空')
293
+ // data 阶段之二:从已发布成品行读取右侧摘要(plat_bulletin_publish_doc.sum_json)
294
+ // sum_json 是发布时缓存的摘要快照;为空/解析失败则摘要置空(右侧面板自动隐藏)
295
+ applySummaryFromDoc(doc) {
296
+ const raw = doc && doc.sumJson
297
+ if (!raw || typeof raw !== 'string') {
298
+ console.log(LOG, 'applySummaryFromDoc 摘要为空,置空', { docId: doc && doc.docId })
272
299
  this.summaryData = null
273
300
  return
274
301
  }
275
- console.log(LOG, 'loadSummary 请求摘要', { bulletinWidgetId, params })
276
- this.summaryLoading = true
277
- getBulletinSummary(bulletinWidgetId, params || {})
278
- .then(res => {
279
- const body = res && res.data
280
- if (!body || body.code !== 1) {
281
- console.warn(LOG, 'loadSummary 失败:', body)
282
- this.summaryData = null
283
- return
284
- }
285
- this.summaryData = (body.data && typeof body.data === 'object') ? body.data : null
286
- const s = this.summaryData || {}
287
- console.log(LOG, 'loadSummary 成功', {
288
- conclusions: (s.conclusions || []).length,
289
- metrics: (s.metrics || []).length,
290
- highlights: (s.highlights || []).length,
291
- risks: (s.risks || []).length,
292
- advices: (s.advices || []).length,
293
- warnings: (s.warnings || []).length
294
- })
295
- })
296
- .catch(err => {
297
- console.error(LOG, 'loadSummary 异常:', err)
302
+ try {
303
+ const parsed = JSON.parse(raw)
304
+ if (!parsed || typeof parsed !== 'object') {
298
305
  this.summaryData = null
306
+ return
307
+ }
308
+ // 库内 meta 是生成时的"统计时间",发布后以发布人为准(发布时间已在左侧目录下方展示)
309
+ const publisher = doc.publishUserName ? String(doc.publishUserName).trim() : ''
310
+ parsed.meta = publisher ? ('发布人:' + publisher) : ''
311
+ this.summaryData = parsed
312
+ console.log(LOG, 'applySummaryFromDoc 摘要已加载', {
313
+ docId: doc.docId,
314
+ conclusions: (parsed.conclusions || []).length,
315
+ metrics: (parsed.metrics || []).length,
316
+ highlights: (parsed.highlights || []).length,
317
+ risks: (parsed.risks || []).length,
318
+ advices: (parsed.advices || []).length
299
319
  })
300
- .finally(() => {
301
- this.summaryLoading = false
302
- })
320
+ } catch (e) {
321
+ console.warn(LOG, 'applySummaryFromDoc sum_json 解析失败,置空:', e)
322
+ this.summaryData = null
323
+ }
303
324
  },
304
325
  // 下载:用当前展示的已发布成品 docId
305
326
  doDownload() {
@@ -349,9 +370,16 @@ export default {
349
370
  .bulletin-widget {
350
371
  width: 100%;
351
372
  }
352
- /* 自绘标题栏:无边框,仅与下方内容留间距 */
373
+ /* 自绘标题栏:左标题右按钮,无边框,仅与下方内容留间距 */
353
374
  .bulletin-header {
354
- padding: 0 0 12px;
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: space-between;
378
+ gap: 12px;
379
+ padding: 0 0 3px;
380
+ }
381
+ .bulletin-title-wrap {
382
+ min-width: 0;
355
383
  }
356
384
  .bulletin-title {
357
385
  font-size: 16px;
@@ -367,44 +395,30 @@ export default {
367
395
  }
368
396
  .bulletin-body {
369
397
  width: 100%;
370
- /* 定位上下文:悬浮下载按钮相对本容器绝对定位 */
371
- position: relative;
372
- /* 有文档预览时给 lambo-doc-word 一个明确的高度基线,其内部 word-preview 使用 height:100% */
373
- height: 720px;
398
+ /* 视口自适应高度:吃满屏幕剩余空间,大屏不留白;减去的值覆盖页头/查询栏/简报标题栏/边距 */
399
+ height: calc(100vh - 210px);
374
400
  min-height: 480px;
375
401
  }
376
- /* 悬浮下载按钮:自定义 FAB,悬浮在预览区右上角上层,hover 点亮为主题蓝 */
377
- .bulletin-download-fab {
378
- position: absolute;
379
- top: 16px;
380
- right: 16px;
381
- z-index: 10;
382
- width: 40px;
383
- height: 40px;
384
- display: flex;
402
+ /* 下载按钮:标题栏右侧,文字链接式,主题蓝可点,hover 加深 */
403
+ .bulletin-download-btn {
404
+ flex-shrink: 0;
405
+ display: inline-flex;
385
406
  align-items: center;
386
- justify-content: center;
387
- font-size: 20px;
407
+ gap: 4px;
408
+ font-size: 13px;
409
+ font-weight: 500;
388
410
  color: #2d8cf0;
389
411
  cursor: pointer;
390
- border-radius: 6px;
391
- background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 247, 255, 0.9));
392
- backdrop-filter: blur(8px);
393
- -webkit-backdrop-filter: blur(8px);
394
- border: 1px solid rgba(45, 140, 240, 0.15);
395
- box-shadow: 0 4px 14px rgba(31, 45, 61, 0.12);
396
- transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
412
+ transition: color 0.18s ease;
413
+ }
414
+ .bulletin-download-btn .btn-icon {
415
+ font-size: 16px;
397
416
  }
398
- .bulletin-download-fab:hover {
399
- color: #fff;
400
- background: linear-gradient(135deg, #2d8cf0, #57a3f3);
401
- border-color: transparent;
402
- transform: translateY(-2px);
403
- box-shadow: 0 8px 20px rgba(45, 140, 240, 0.4);
417
+ .bulletin-download-btn:hover {
418
+ color: #57a3f3;
404
419
  }
405
- .bulletin-download-fab:active {
406
- transform: translateY(0);
407
- box-shadow: 0 3px 10px rgba(45, 140, 240, 0.35);
420
+ .bulletin-download-btn:active {
421
+ color: #2372c8;
408
422
  }
409
423
  /* 空态/加载态:无文档时高度自适应内容,避免撑出大片空白 */
410
424
  .bulletin-body.is-empty {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambo-design-bi",
3
- "version": "0.0.248",
3
+ "version": "0.0.251",
4
4
  "description": "Vue2 BI 数据可视化组件库",
5
5
  "main": "index.js",
6
6
  "author": "finch",
@@ -1,8 +1,14 @@
1
1
  <template>
2
2
  <div class="word-preview">
3
- <!-- 左侧:目录(从真实文档动态提取) -->
4
- <aside class="word-nav">
5
- <div class="word-nav-title">文档目录</div>
3
+ <!-- 左侧:目录(可折叠) -->
4
+ <aside class="word-nav" v-if="!navCollapsed">
5
+ <div class="word-nav-head">
6
+ <div class="word-nav-title">文档目录</div>
7
+ <span class="panel-collapse-btn" title="收起目录" @click="toggleNav">
8
+ <Icon type="ios-arrow-back" />
9
+ </span>
10
+ </div>
11
+ <div class="word-nav-publish" v-if="publishTime">{{ publishTime }}</div>
6
12
  <div v-if="!ready" class="word-nav-empty">解析中…</div>
7
13
  <div v-else-if="!toc.length" class="word-nav-empty">未识别到章节目录</div>
8
14
  <a
@@ -13,6 +19,11 @@
13
19
  @click="scrollTo(item)"
14
20
  >{{ item.text }}</a>
15
21
  </aside>
22
+ <!-- 左侧折叠窄条 -->
23
+ <div class="word-rail word-rail-left" v-else title="展开目录" @click="toggleNav">
24
+ <Icon type="ios-arrow-forward" class="rail-arrow" />
25
+ <span class="rail-text">文档目录</span>
26
+ </div>
16
27
 
17
28
  <!-- 中间:真实 Word 预览 -->
18
29
  <div class="word-doc">
@@ -23,8 +34,17 @@
23
34
  ></js-preview-docx>
24
35
  </div>
25
36
 
26
- <!-- 右侧:摘要(四部分全空时隐藏,中间预览区自动扩展) -->
27
- <aside class="word-aside" v-if="!summaryEmpty">
37
+ <!-- 右侧折叠窄条(摘要非空且已折叠时显示) -->
38
+ <div class="word-rail word-rail-right" v-if="!summaryEmpty && asideCollapsed" title="展开摘要" @click="toggleAside">
39
+ <Icon type="ios-arrow-back" class="rail-arrow" />
40
+ <span class="rail-text">文档摘要</span>
41
+ </div>
42
+
43
+ <!-- 右侧:摘要(四部分全空时隐藏,中间预览区自动扩展;可折叠) -->
44
+ <aside class="word-aside" v-if="!summaryEmpty && !asideCollapsed">
45
+ <span class="panel-collapse-btn panel-collapse-btn-float" title="收起摘要" @click="toggleAside">
46
+ <Icon type="ios-arrow-forward" />
47
+ </span>
28
48
  <div class="paper-title" v-if="internalReport.title">{{ internalReport.title }}</div>
29
49
  <div class="paper-meta" v-if="internalReport.meta">{{ internalReport.meta }}</div>
30
50
 
@@ -81,6 +101,10 @@
81
101
  import JsPreviewDocx from "../preview/components/JsPreviewDocx.vue"
82
102
  import JSZip from "jszip"
83
103
 
104
+ // 折叠状态在 localStorage 的存储 key
105
+ const NAV_COLLAPSE_KEY = "lambo-doc-word-nav-collapsed"
106
+ const ASIDE_COLLAPSE_KEY = "lambo-doc-word-aside-collapsed"
107
+
84
108
  export default {
85
109
  name: "LamboDocWord",
86
110
  components: {
@@ -96,6 +120,11 @@ export default {
96
120
  report: {
97
121
  type: Object,
98
122
  default: null
123
+ },
124
+ // 简报生成时间(plat_bulletin_publish_doc.publish_time),展示在目录下方
125
+ publishTime: {
126
+ type: String,
127
+ default: ""
99
128
  }
100
129
  },
101
130
  data() {
@@ -144,7 +173,10 @@ export default {
144
173
  scrollEl: null,
145
174
  // 右侧摘要(优先使用外部 report prop,缺失时回退到内置模拟数据)
146
175
  internalReport: this.report || defaultReport,
147
- defaultReport: defaultReport
176
+ defaultReport: defaultReport,
177
+ // 折叠状态(从 localStorage 恢复,容错降级为默认展开)
178
+ navCollapsed: this.readCollapsed(NAV_COLLAPSE_KEY),
179
+ asideCollapsed: this.readCollapsed(ASIDE_COLLAPSE_KEY)
148
180
  };
149
181
  },
150
182
  watch: {
@@ -183,6 +215,34 @@ export default {
183
215
  }
184
216
  },
185
217
  methods: {
218
+ // 读取 localStorage 中的折叠状态;不可用或无值时默认展开(false)
219
+ readCollapsed(key) {
220
+ try {
221
+ return typeof window !== "undefined" && window.localStorage
222
+ ? window.localStorage.getItem(key) === "1"
223
+ : false;
224
+ } catch (e) {
225
+ return false;
226
+ }
227
+ },
228
+ // 写入折叠状态到 localStorage(容错,不阻断切换)
229
+ writeCollapsed(key, val) {
230
+ try {
231
+ if (typeof window !== "undefined" && window.localStorage) {
232
+ window.localStorage.setItem(key, val ? "1" : "0");
233
+ }
234
+ } catch (e) {
235
+ // ignore
236
+ }
237
+ },
238
+ toggleNav() {
239
+ this.navCollapsed = !this.navCollapsed;
240
+ this.writeCollapsed(NAV_COLLAPSE_KEY, this.navCollapsed);
241
+ },
242
+ toggleAside() {
243
+ this.asideCollapsed = !this.asideCollapsed;
244
+ this.writeCollapsed(ASIDE_COLLAPSE_KEY, this.asideCollapsed);
245
+ },
186
246
  // JsPreviewDocx 渲染完成,root 为其容器 DOM(.vue-office-docx,即滚动容器)
187
247
  async onRendered(root) {
188
248
  this.docRoot = root;
@@ -400,7 +460,7 @@ export default {
400
460
  width: 100%;
401
461
  height: 100%;
402
462
  box-sizing: border-box;
403
- padding: 6px;
463
+ padding: 5px;
404
464
  background: #f3f4f6;
405
465
  color: #1e293b;
406
466
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
@@ -417,9 +477,77 @@ export default {
417
477
  background: #fff;
418
478
  border: 1px solid #e2e8f0;
419
479
  border-radius: 6px;
420
- padding: 14px;
480
+ padding: 7px 8px;
421
481
  box-sizing: border-box;
422
482
  overflow-y: auto;
483
+ transition: flex-basis 0.25s cubic-bezier(0.4, 0, 0.2, 1),
484
+ width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
485
+ }
486
+ /* 目录标题行:标题 + 收起按钮 */
487
+ .word-nav-head {
488
+ display: flex;
489
+ align-items: center;
490
+ justify-content: space-between;
491
+ margin-bottom: 6px;
492
+ }
493
+ .word-nav-head .word-nav-title {
494
+ margin-bottom: 0;
495
+ }
496
+ /* 摘要收起按钮:悬浮右上角,不占文档流,避免挤压标题到第二行 */
497
+ .panel-collapse-btn-float {
498
+ position: absolute;
499
+ top: 7px;
500
+ right: 8px;
501
+ z-index: 2;
502
+ }
503
+ /* 面板内收起按钮:纯图标,克制 */
504
+ .panel-collapse-btn {
505
+ display: inline-flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ width: 22px;
509
+ height: 22px;
510
+ border-radius: 6px;
511
+ color: #94a3b8;
512
+ cursor: pointer;
513
+ font-size: 16px;
514
+ transition: background 0.15s, color 0.15s;
515
+ }
516
+ .panel-collapse-btn:hover {
517
+ background: #f0f7ff;
518
+ color: #2d8cf0;
519
+ }
520
+ /* 折叠窄条:竖排标题 + 箭头,点击整条展开 */
521
+ .word-rail {
522
+ flex: 0 0 32px;
523
+ width: 32px;
524
+ align-self: stretch;
525
+ display: flex;
526
+ flex-direction: column;
527
+ align-items: center;
528
+ gap: 8px;
529
+ padding: 10px 0;
530
+ background: #fff;
531
+ border: 1px solid #e2e8f0;
532
+ border-radius: 6px;
533
+ cursor: pointer;
534
+ color: #64748b;
535
+ transition: background 0.15s, color 0.15s;
536
+ }
537
+ .word-rail:hover {
538
+ background: #f0f7ff;
539
+ color: #2d8cf0;
540
+ }
541
+ .word-rail .rail-arrow {
542
+ font-size: 16px;
543
+ flex-shrink: 0;
544
+ }
545
+ .word-rail .rail-text {
546
+ writing-mode: vertical-rl;
547
+ letter-spacing: 2px;
548
+ font-size: 13px;
549
+ font-weight: 600;
550
+ user-select: none;
423
551
  }
424
552
  .word-nav-title {
425
553
  font-size: 14px;
@@ -467,6 +595,13 @@ export default {
467
595
  color: #1e40af;
468
596
  font-weight: 700;
469
597
  }
598
+ /* 文档目录标题下方:简报发布时间,与右侧 .paper-meta 同一套样式 */
599
+ .word-nav-publish {
600
+ margin: 4px 0 6px;
601
+ font-size: 11px;
602
+ color: #64748b;
603
+ line-height: 1.6;
604
+ }
470
605
 
471
606
  /* 中间 Word 预览 */
472
607
  .word-doc {
@@ -475,7 +610,6 @@ export default {
475
610
  display: flex;
476
611
  flex-direction: column;
477
612
  background: #fff;
478
- border: 1px solid #e2e8f0;
479
613
  border-radius: 6px;
480
614
  overflow: hidden;
481
615
  box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
@@ -493,26 +627,31 @@ export default {
493
627
 
494
628
  /* 右侧摘要 */
495
629
  .word-aside {
496
- flex: 0 0 300px;
497
- width: 300px;
630
+ position: relative;
631
+ flex: 0 0 290px;
632
+ width: 290px;
498
633
  align-self: stretch;
499
634
  background: #fff;
500
635
  border: 1px solid #e2e8f0;
501
636
  border-radius: 6px;
502
- padding: 16px;
637
+ padding: 7px 8px;
503
638
  box-sizing: border-box;
504
639
  overflow-y: auto;
640
+ transition: flex-basis 0.25s cubic-bezier(0.4, 0, 0.2, 1),
641
+ width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
505
642
  }
506
643
  .paper-title {
507
644
  font-size: 14px;
508
645
  font-weight: 700;
509
646
  color: #0f172a;
510
- margin-bottom: 4px;
647
+ margin-bottom: 6px;
648
+ /* 给右上角悬浮收起按钮让位 */
649
+ padding-right: 26px;
511
650
  }
512
651
  .paper-meta {
513
- font-size: 12px;
652
+ font-size: 11px;
514
653
  color: #64748b;
515
- margin-bottom: 16px;
654
+ margin-bottom: 6px;
516
655
  }
517
656
  /* 摘要分区 */
518
657
  .summary-section {
@@ -277,10 +277,10 @@ export default {
277
277
  }
278
278
  },
279
279
  handleSubmit() {
280
- if (!this.applyDescription.trim()) {
281
- this.$Message.warning({ content: '请输入申请描述', duration: 2.5 })
282
- return
283
- }
280
+ // if (!this.applyDescription.trim()) {
281
+ // this.$Message.warning({ content: '请输入申请描述', duration: 2.5 })
282
+ // return
283
+ // }
284
284
  this.submitting = true
285
285
  this.$emit('submit', {
286
286
  applyDescription: this.applyDescription,
@@ -0,0 +1,3 @@
1
+ import LamboTableStatusFlowCol from './index.vue'
2
+ export default LamboTableStatusFlowCol
3
+ export { LamboTableStatusFlowCol }
@@ -0,0 +1,320 @@
1
+ <!--
2
+ LamboTableStatusFlowCol 表格状态流转列组件
3
+
4
+ 用途:
5
+ 在表格单元格中以「节点1 › 节点2 › 节点3 ...」的横向流转条形式展示业务单据的当前状态,
6
+ 并支持点击打开抽屉查看完整的状态流转时间线(含每一步的时间、操作人、备注等历史信息)。
7
+
8
+ 展示规则:
9
+ - 总状态数 <= 3:全部节点展开显示。
10
+ - 总状态数 > 3:当前节点之前仅保留一个直接前置(多个折叠为 +N),
11
+ 当前节点之后最多显示一个直接后置(剩余 1 个显示名称,多个折叠为 +N)。
12
+ - 已完成节点使用成功色(绿色),待处理节点使用表格常规文字色,当前节点高亮。
13
+
14
+ Props:
15
+ - statuses {Array<{ K: string|number, V: string }>} 状态节点列表,按流转顺序排列,
16
+ K 为状态值(与业务数据中的状态字段对应),V 为状态显示名称,K 需唯一。
17
+ 示例:
18
+ [
19
+ { K: '10', V: '新建' },
20
+ { K: '20', V: '审批中' },
21
+ { K: '30', V: '已通过' },
22
+ { K: '40', V: '已完成' }
23
+ ]
24
+ - current {string|number} 当前单据所处的状态值,需与 statuses 中某项的 K 相等
25
+ (比较时会做 String() 转换,数字与字符串形式可互通)。示例:'20' 或 20。
26
+ - history {Array<{ K: string|number, time?: string, operator?: string, comment?: string }>}
27
+ 可选,各状态节点的历史记录,用于在抽屉时间线中展示时间/操作人/备注;
28
+ K 需与 statuses 中的 K 对应,相同 K 默认只取一条。示例:
29
+ [
30
+ { K: '10', time: '2026-08-01 09:00', operator: '张三', comment: '提交申请' },
31
+ { K: '20', time: '2026-08-01 10:30', operator: '李四', comment: '审批中' }
32
+ ]
33
+
34
+ 用法示例:
35
+ <LamboTableStatusFlowCol
36
+ :statuses="[
37
+ { K: '10', V: '新建' },
38
+ { K: '20', V: '审批中' },
39
+ { K: '30', V: '已通过' }
40
+ ]"
41
+ current="20"
42
+ :history="[
43
+ { K: '10', time: '2026-08-01 09:00', operator: '张三', comment: '提交申请' }
44
+ ]"
45
+ />
46
+
47
+ 事件:无。
48
+ -->
49
+ <template>
50
+ <div class="status-flow-wrapper">
51
+ <div class="status-flow" title="点击查看完整流转" @click="drawerVisible = true">
52
+ <template v-if="currentIndex >= 0">
53
+ <!-- 总状态数 <=3:全部展开,不折叠 -->
54
+ <template v-if="expandAll">
55
+ <span
56
+ v-for="(node, i) in statuses"
57
+ :key="node.K"
58
+ :class="i < currentIndex ? 'collapsed-prev' : (i === currentIndex ? 'current' : 'normal')"
59
+ >{{ node.V }}<span v-if="i < statuses.length - 1" class="arrow">›</span></span>
60
+ </template>
61
+
62
+ <!-- 总状态数 >3:前置/后置按规则折叠 -->
63
+ <template v-else>
64
+ <!-- 前置节点:1 个直接显示名称,多个折叠为 +N(成功色,已完成) -->
65
+ <template v-if="currentIndex > 0">
66
+ <span v-if="currentIndex === 1" class="collapsed-prev">{{ prevName }}</span>
67
+ <span v-else class="collapsed-prev">+{{ currentIndex }}</span>
68
+ <span class="arrow">›</span>
69
+ </template>
70
+
71
+ <!-- 当前状态 - 成功色 -->
72
+ <span class="current">{{ currentName }}</span>
73
+
74
+ <!-- 后置:紧挨的下一个直接显示;剩余 1 个显示名称,多个折叠为 +N -->
75
+ <template v-if="nextIndex < statuses.length">
76
+ <span class="arrow">›</span>
77
+ <span class="normal">{{ nextName }}</span>
78
+
79
+ <template v-if="remainingCount === 1">
80
+ <span class="arrow">›</span>
81
+ <span class="normal">{{ remainingName }}</span>
82
+ </template>
83
+ <template v-else-if="remainingCount > 1">
84
+ <span class="arrow">›</span>
85
+ <span class="collapsed-next">+{{ remainingCount }}</span>
86
+ </template>
87
+ </template>
88
+ </template>
89
+ </template>
90
+ <span v-else>-</span>
91
+ </div>
92
+
93
+ <Drawer
94
+ v-model="drawerVisible"
95
+ title="完整状态流转"
96
+ :width="400"
97
+ :mask-closable="true"
98
+ >
99
+ <div class="timeline">
100
+ <div v-for="(node, i) in statuses" :key="node.K" class="timeline-item">
101
+ <div
102
+ class="timeline-dot"
103
+ :class="{ completed: i < currentIndex, current: i === currentIndex }"
104
+ ></div>
105
+ <div class="timeline-label">
106
+ {{ node.V }}
107
+ <span
108
+ class="badge"
109
+ :class="badgeClass(i)"
110
+ >{{ badgeText(i) }}</span>
111
+ </div>
112
+ <div v-if="historyMap[node.K]" class="timeline-meta">
113
+ <div v-if="historyMap[node.K].time" class="meta-line">
114
+ 时间:{{ historyMap[node.K].time }}
115
+ </div>
116
+ <div v-if="historyMap[node.K].operator" class="meta-line">
117
+ 操作人:{{ historyMap[node.K].operator }}
118
+ </div>
119
+ <div v-if="historyMap[node.K].comment" class="meta-line">
120
+ 备注:{{ historyMap[node.K].comment }}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </Drawer>
126
+ </div>
127
+ </template>
128
+
129
+ <script>
130
+ export default {
131
+ name: 'LamboTableStatusFlowCol',
132
+ props: {
133
+ statuses: {
134
+ type: Array,
135
+ required: true
136
+ // [{ K: '10', V: '新建' }, ...]
137
+ },
138
+ current: {
139
+ type: [String, Number],
140
+ required: true
141
+ },
142
+ history: {
143
+ type: Array,
144
+ default: () => []
145
+ // [{ K: '20', time: '...', operator: '...', comment: '...' }]
146
+ }
147
+ },
148
+ data() {
149
+ return {
150
+ drawerVisible: false
151
+ }
152
+ },
153
+ computed: {
154
+ currentIndex() {
155
+ return this.statuses.findIndex(s => String(s.K) === String(this.current))
156
+ },
157
+ expandAll() {
158
+ return this.statuses.length <= 3
159
+ },
160
+ nextIndex() {
161
+ return this.currentIndex + 1
162
+ },
163
+ remainingCount() {
164
+ return Math.max(0, this.statuses.length - this.currentIndex - 2)
165
+ },
166
+ currentName() {
167
+ const node = this.statuses[this.currentIndex]
168
+ return node ? node.V : ''
169
+ },
170
+ nextName() {
171
+ const node = this.statuses[this.nextIndex]
172
+ return node ? node.V : ''
173
+ },
174
+ prevName() {
175
+ const node = this.statuses[this.currentIndex - 1]
176
+ return node ? node.V : ''
177
+ },
178
+ remainingName() {
179
+ const node = this.statuses[this.nextIndex + 1]
180
+ return node ? node.V : ''
181
+ },
182
+ historyMap() {
183
+ const map = {}
184
+ this.history.forEach(h => {
185
+ map[h.K] = h
186
+ })
187
+ return map
188
+ }
189
+ },
190
+ methods: {
191
+ badgeClass(i) {
192
+ if (i < this.currentIndex) return 'badge-completed'
193
+ if (i === this.currentIndex) return 'badge-processing'
194
+ return 'badge-pending'
195
+ },
196
+ badgeText(i) {
197
+ if (i < this.currentIndex) return '已完成'
198
+ if (i === this.currentIndex) return '进行中'
199
+ return '待处理'
200
+ }
201
+ }
202
+ }
203
+ </script>
204
+
205
+ <style scoped>
206
+ .status-flow {
207
+ cursor: pointer;
208
+ display: inline-flex;
209
+ align-items: center;
210
+ gap: 4px;
211
+ padding: 4px 0;
212
+ transition: opacity 0.3s;
213
+ font-size: 14px;
214
+ color: #515a6e;
215
+ }
216
+
217
+ .status-flow:hover {
218
+ opacity: 0.7;
219
+ }
220
+
221
+ /* 前置折叠 - 成功色(已完成) */
222
+ .collapsed-prev {
223
+ color: #19be6b;
224
+ }
225
+
226
+ /* 后置折叠 - 与表格文字一致(待处理) */
227
+ .collapsed-next {
228
+ color: #515a6e;
229
+ }
230
+
231
+ /* 箭头 */
232
+ .arrow {
233
+ color: #c5c8ce;
234
+ margin: 0 2px;
235
+ }
236
+
237
+ /* 当前状态 - 成功色 */
238
+ .current {
239
+ color: #19be6b;
240
+ }
241
+
242
+ /* 下一节点 - 与表格文字一致 */
243
+ .normal {
244
+ color: #515a6e;
245
+ }
246
+
247
+ /* 抽屉内时间线 */
248
+ .timeline {
249
+ position: relative;
250
+ padding-left: 30px;
251
+ }
252
+
253
+ .timeline::before {
254
+ content: '';
255
+ position: absolute;
256
+ left: 10px;
257
+ top: 10px;
258
+ bottom: 10px;
259
+ width: 2px;
260
+ background: #e8eaec;
261
+ }
262
+
263
+ .timeline-item {
264
+ position: relative;
265
+ margin-bottom: 25px;
266
+ }
267
+
268
+ .timeline-item:last-child {
269
+ margin-bottom: 0;
270
+ }
271
+
272
+ .timeline-dot {
273
+ position: absolute;
274
+ left: -25px;
275
+ top: 5px;
276
+ width: 12px;
277
+ height: 12px;
278
+ border-radius: 50%;
279
+ background: #c5c8ce;
280
+ border: 2px solid white;
281
+ box-shadow: 0 0 0 2px #c5c8ce;
282
+ }
283
+
284
+ .timeline-dot.completed,
285
+ .timeline-dot.current {
286
+ background: #19be6b;
287
+ box-shadow: 0 0 0 2px #19be6b;
288
+ }
289
+
290
+ .timeline-label {
291
+ font-size: 14px;
292
+ color: #333;
293
+ }
294
+
295
+ .badge {
296
+ display: inline-block;
297
+ padding: 2px 8px;
298
+ border-radius: 12px;
299
+ font-size: 12px;
300
+ margin-left: 8px;
301
+ }
302
+
303
+ .badge-processing,
304
+ .badge-completed {
305
+ background: #e8f8ef;
306
+ color: #19be6b;
307
+ }
308
+
309
+ .badge-pending {
310
+ background: #f5f5f5;
311
+ color: #999;
312
+ }
313
+
314
+ .timeline-meta {
315
+ margin-top: 6px;
316
+ font-size: 12px;
317
+ color: #999;
318
+ line-height: 1.8;
319
+ }
320
+ </style>